atsfill 0.0.5.dev141__tar.gz → 0.0.5.dev143__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.dev141 → atsfill-0.0.5.dev143}/PKG-INFO +4 -1
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/README.md +2 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/pyproject.toml +2 -1
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/pyproject.toml.orig +2 -1
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/cli.py +2 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/core/commands.py +10 -3
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/core/paths.py +8 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/core/registry.py +10 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/core/results.py +1 -0
- atsfill-0.0.5.dev143/src/atsfill/core/telemetry.py +163 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/registry.toml +19 -6
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/LICENSE +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/adapters/gupy.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/adapters/hrmdirect.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/adapters/isolvedhire.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/adapters/join.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/adapters/successfactors.js +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/adapters/successfactors.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/adapters/taleo.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/adapters/ukg.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/core/otp.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev143}/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.dev143
|
|
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
|
|
@@ -20,6 +20,7 @@ Requires-Dist: camoufox==0.5.8
|
|
|
20
20
|
Requires-Dist: filelock==4.1.1
|
|
21
21
|
Requires-Dist: keyring==25.7.0
|
|
22
22
|
Requires-Dist: packaging==26.3
|
|
23
|
+
Requires-Dist: posthog==7.78.0
|
|
23
24
|
Requires-Python: >=3.14
|
|
24
25
|
Project-URL: Homepage, https://github.com/shbernal/atsfill
|
|
25
26
|
Project-URL: Repository, https://github.com/shbernal/atsfill
|
|
@@ -152,6 +153,8 @@ The core owns every rule about when a send is allowed; an adapter only knows its
|
|
|
152
153
|
Every result has a `status` (`ready`, `refused`, `submitted`, `blocked`, `unknown`, `unsupported`) and, when it is not clean, a `reason` code.
|
|
153
154
|
The exit code is 0 for `ready` and `submitted`, 1 otherwise, 2 for a usage error.
|
|
154
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).
|
|
157
|
+
|
|
155
158
|
## More
|
|
156
159
|
|
|
157
160
|
- [Architecture decisions](docs/adr/README.md): the contract, the submit gate, the browser, sessions.
|
|
@@ -124,6 +124,8 @@ 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).
|
|
128
|
+
|
|
127
129
|
## More
|
|
128
130
|
|
|
129
131
|
- [Architecture decisions](docs/adr/README.md): the contract, the submit gate, the browser, sessions.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "atsfill"
|
|
3
|
-
version = "0.0.5.
|
|
3
|
+
version = "0.0.5.dev143"
|
|
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"
|
|
@@ -32,6 +32,7 @@ dependencies = [
|
|
|
32
32
|
"filelock==4.1.1",
|
|
33
33
|
"keyring==25.7.0",
|
|
34
34
|
"packaging==26.3",
|
|
35
|
+
"posthog==7.78.0",
|
|
35
36
|
]
|
|
36
37
|
|
|
37
38
|
[[project.authors]]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "atsfill"
|
|
3
|
-
version = "0.0.5.
|
|
3
|
+
version = "0.0.5.dev143"
|
|
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"
|
|
@@ -25,6 +25,7 @@ dependencies = [
|
|
|
25
25
|
"filelock==4.1.1",
|
|
26
26
|
"keyring==25.7.0",
|
|
27
27
|
"packaging==26.3",
|
|
28
|
+
"posthog==7.78.0",
|
|
28
29
|
]
|
|
29
30
|
|
|
30
31
|
[project.urls]
|
|
@@ -190,6 +190,8 @@ def run(args: argparse.Namespace) -> int:
|
|
|
190
190
|
return 2
|
|
191
191
|
emit(session, args.verbose)
|
|
192
192
|
return 0 if args.command == "logout" or session.session == "signed_in" else 1
|
|
193
|
+
if result.notice:
|
|
194
|
+
print(f"atsfill: {result.notice}", file=sys.stderr)
|
|
193
195
|
emit(result, args.verbose, args.command)
|
|
194
196
|
return 0 if result.status in OK else 1
|
|
195
197
|
|
|
@@ -15,6 +15,7 @@ from atsfill.core import (
|
|
|
15
15
|
passwords,
|
|
16
16
|
paths,
|
|
17
17
|
registry,
|
|
18
|
+
telemetry,
|
|
18
19
|
)
|
|
19
20
|
from atsfill.core.adapter import (
|
|
20
21
|
Adapter,
|
|
@@ -353,6 +354,11 @@ def read(
|
|
|
353
354
|
) -> Result:
|
|
354
355
|
"""Lists the form's fields by a request read when an adapter has one (ADR 0011), else by a
|
|
355
356
|
browser read."""
|
|
357
|
+
data = paths.data_dir(data_dir)
|
|
358
|
+
return telemetry.sent("read", _read(data, user, url, sessions), data)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _read(data: Path, user: str, url: str, sessions: Sessions | None) -> Result:
|
|
356
362
|
for adapter in adapters():
|
|
357
363
|
if not isinstance(adapter, ReadsByRequest):
|
|
358
364
|
continue
|
|
@@ -364,7 +370,6 @@ def read(
|
|
|
364
370
|
return Result(Status.UNSUPPORTED, url, adapter.name, f"adapter_error: {e}")
|
|
365
371
|
_unfillable(result)
|
|
366
372
|
return result
|
|
367
|
-
data = paths.data_dir(data_dir)
|
|
368
373
|
return _in_budget(url, lambda seen: _browser_read(data, user, url, sessions, seen))
|
|
369
374
|
|
|
370
375
|
|
|
@@ -377,9 +382,10 @@ def fill(
|
|
|
377
382
|
sessions: Sessions | None = None,
|
|
378
383
|
) -> Result:
|
|
379
384
|
data = paths.data_dir(data_dir)
|
|
380
|
-
|
|
385
|
+
result = _in_budget(
|
|
381
386
|
url, lambda seen: _form_command(data, user, url, answers, sessions, False, False, seen)
|
|
382
387
|
)
|
|
388
|
+
return telemetry.sent("fill", result, data)
|
|
383
389
|
|
|
384
390
|
|
|
385
391
|
def send(
|
|
@@ -392,9 +398,10 @@ def send(
|
|
|
392
398
|
sessions: Sessions | None = None,
|
|
393
399
|
) -> Result:
|
|
394
400
|
data = paths.data_dir(data_dir)
|
|
395
|
-
|
|
401
|
+
result = _in_budget(
|
|
396
402
|
url, lambda seen: _form_command(data, user, url, answers, sessions, True, resend, seen)
|
|
397
403
|
)
|
|
404
|
+
return telemetry.sent("send", result, data)
|
|
398
405
|
|
|
399
406
|
|
|
400
407
|
def _sessions(data: Path, users: list[str], candidates: list[Adapter]) -> list[SessionReport]:
|
|
@@ -12,6 +12,7 @@ TEST_USER = "test"
|
|
|
12
12
|
USER_NAME = re.compile(r"[a-z0-9-]+")
|
|
13
13
|
RENAMED = {"submit": "submit-allowed", "ack_side_effects": "mute-side-effect-warnings"}
|
|
14
14
|
MOVED = ("sessions", "attempts.jsonl") # a data directory from before users, the default's
|
|
15
|
+
TELEMETRY = ("off", "basic", "detailed") # the first is no telemetry (ADR 0016)
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
class ConfigError(Exception):
|
|
@@ -72,6 +73,8 @@ def _config() -> dict:
|
|
|
72
73
|
for old, new in RENAMED.items():
|
|
73
74
|
if old in section:
|
|
74
75
|
raise ConfigError(f"`{old}` in {path} is now `{new}`")
|
|
76
|
+
if (level := whole.get("telemetry", "basic")) not in TELEMETRY:
|
|
77
|
+
raise ConfigError(f"telemetry = {level!r} in {path}: one of {', '.join(TELEMETRY)}")
|
|
75
78
|
return whole
|
|
76
79
|
|
|
77
80
|
|
|
@@ -84,6 +87,11 @@ def config(user: str) -> dict:
|
|
|
84
87
|
return whole.get("users", {}).get(user, {})
|
|
85
88
|
|
|
86
89
|
|
|
90
|
+
def telemetry() -> str:
|
|
91
|
+
"""The home's telemetry level, a top-level key: `basic` unless the config says otherwise."""
|
|
92
|
+
return _config().get("telemetry", "basic")
|
|
93
|
+
|
|
94
|
+
|
|
87
95
|
def submit_enabled(adapter: str, user: str) -> bool:
|
|
88
96
|
"""Whether the user's `submit-allowed` lists the adapter: there is no other way to enable
|
|
89
97
|
it."""
|
|
@@ -6,6 +6,7 @@ from dataclasses import dataclass, fields
|
|
|
6
6
|
from datetime import date
|
|
7
7
|
from functools import cache
|
|
8
8
|
from importlib.resources import files
|
|
9
|
+
from urllib.parse import urlsplit
|
|
9
10
|
|
|
10
11
|
KINDS = ("ats", "board", "aggregator", "custom")
|
|
11
12
|
ACCOUNTS = ("none", "to_send", "to_read") # also the backlog order (ADR 0013)
|
|
@@ -46,6 +47,7 @@ class Source:
|
|
|
46
47
|
account: str
|
|
47
48
|
hosting: tuple[str, ...]
|
|
48
49
|
adapter: str | None = None
|
|
50
|
+
hosts: tuple[str, ...] = () # a source without an adapter: where its postings are
|
|
49
51
|
share: float | None = None
|
|
50
52
|
links_to: tuple[str, ...] = () # an aggregator's: the sources its postings link to
|
|
51
53
|
block: Block | None = None
|
|
@@ -80,6 +82,7 @@ def _source(name: str, entry: dict) -> Source:
|
|
|
80
82
|
entry["account"],
|
|
81
83
|
tuple(entry.get("hosting", [])),
|
|
82
84
|
entry.get("adapter"),
|
|
85
|
+
tuple(entry.get("hosts", [])),
|
|
83
86
|
entry.get("share"),
|
|
84
87
|
tuple(entry.get("links_to", [])),
|
|
85
88
|
block,
|
|
@@ -121,6 +124,13 @@ def for_adapter(name: str) -> Source | None:
|
|
|
121
124
|
return next((s for s in sources().values() if s.adapter == name), None)
|
|
122
125
|
|
|
123
126
|
|
|
127
|
+
def for_url(url: str) -> Source | None:
|
|
128
|
+
"""The source whose `hosts` a URL is on, a subdomain included."""
|
|
129
|
+
host = urlsplit(url).hostname or ""
|
|
130
|
+
on = lambda h: host == h or host.endswith(f".{h}") # noqa: E731
|
|
131
|
+
return next((s for s in sources().values() if any(map(on, s.hosts))), None)
|
|
132
|
+
|
|
133
|
+
|
|
124
134
|
def _backlog_key(s: Source) -> tuple:
|
|
125
135
|
stopped = s.block is not None or s.scope.reason is not None
|
|
126
136
|
return (stopped, ACCOUNTS.index(s.account), -(s.share or 0), s.name)
|
|
@@ -101,6 +101,7 @@ class Result:
|
|
|
101
101
|
checks: list[Check] = field(default_factory=list)
|
|
102
102
|
side_effects: list[SideEffect] = field(default_factory=list)
|
|
103
103
|
evidence: str | None = None
|
|
104
|
+
notice: str | None = None # telemetry's, the first time it is on (ADR 0016)
|
|
104
105
|
|
|
105
106
|
|
|
106
107
|
@dataclass
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Anonymous telemetry (ADR 0016): one `run` event per `read`, `fill` and `send`, built from
|
|
2
|
+
the command's result. Never in the way: any failure is dropped and the result goes back as it
|
|
3
|
+
came, but for the first-run notice."""
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import uuid
|
|
12
|
+
from contextlib import suppress
|
|
13
|
+
from functools import cache
|
|
14
|
+
from importlib.metadata import PackageNotFoundError, distribution, version
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
17
|
+
|
|
18
|
+
from posthog import Posthog
|
|
19
|
+
|
|
20
|
+
from atsfill.adapters import adapters
|
|
21
|
+
from atsfill.core import paths, registry
|
|
22
|
+
from atsfill.core.results import Result, Status
|
|
23
|
+
|
|
24
|
+
PROJECT_KEY = "phc_xmrdiXySbbZCdUzdbdNUtjMco3rLL2Ko5j4hFrX9fUQ8" # ingestion only, safe to ship
|
|
25
|
+
HOST = "https://eu.i.posthog.com"
|
|
26
|
+
REQUEST_TIMEOUT_SECONDS = 3
|
|
27
|
+
MAX_RETRIES = 1
|
|
28
|
+
FLUSH_TIMEOUT_SECONDS = 2 # the SDK's shutdown() waits without one
|
|
29
|
+
GIT_TIMEOUT_SECONDS = 2
|
|
30
|
+
ROOT = Path(__file__).parents[3] # the checkout, for an editable install
|
|
31
|
+
NOTICE_FILE = "telemetry-notice" # in the data directory, once the notice was shown
|
|
32
|
+
NOTICE = (
|
|
33
|
+
"telemetry: atsfill sends an anonymous event per read, fill and send, no answers or URLs;"
|
|
34
|
+
' set telemetry = "off" in {config} or DO_NOT_TRACK=1 to stop it:'
|
|
35
|
+
" https://github.com/shbernal/atsfill/blob/main/docs/telemetry.md"
|
|
36
|
+
)
|
|
37
|
+
# Reason codes that say nothing about the adapter: the user's input or a fact about the posting
|
|
38
|
+
EXCLUDED = (
|
|
39
|
+
"invalid_answers",
|
|
40
|
+
"answer_not_taken",
|
|
41
|
+
"not_qualified",
|
|
42
|
+
"already_sent",
|
|
43
|
+
"submit_not_enabled",
|
|
44
|
+
"out_of_scope",
|
|
45
|
+
"posting_gone",
|
|
46
|
+
"external_step",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
logging.getLogger("posthog").disabled = True
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _code(reason: str | None) -> str | None:
|
|
53
|
+
return reason.partition(":")[0] if reason else None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def verdict(result: Result) -> str:
|
|
57
|
+
"""What the run says about its adapter: `pass`, `fail`, `excluded` or `unsupported`."""
|
|
58
|
+
if result.status in (Status.READY, Status.SUBMITTED):
|
|
59
|
+
return "pass"
|
|
60
|
+
code = _code(result.reason)
|
|
61
|
+
if code in EXCLUDED:
|
|
62
|
+
return "excluded"
|
|
63
|
+
return "unsupported" if code == "no_adapter" else "fail"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def posting_hash(url: str) -> str:
|
|
67
|
+
"""The URL hashed without its fragment and `utm_` parameters, scheme and host lowercased."""
|
|
68
|
+
parts = urlsplit(url)
|
|
69
|
+
query = urlencode([(k, v) for k, v in parse_qsl(parts.query) if not k.startswith("utm_")])
|
|
70
|
+
normal = urlunsplit((parts.scheme.lower(), parts.netloc.lower(), parts.path, query, ""))
|
|
71
|
+
return hashlib.sha256(normal.encode()).hexdigest()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _posting(result: Result) -> str:
|
|
75
|
+
"""The posting's key where its adapter gives one, as the double-send guard keys it, so
|
|
76
|
+
every URL of a posting hashes the same; else its URL."""
|
|
77
|
+
adapter = next((a for a in adapters() if a.name == result.system), None)
|
|
78
|
+
return adapter.posting(result.url) if adapter else result.url
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _system(result: Result) -> str:
|
|
82
|
+
if result.system:
|
|
83
|
+
return result.system
|
|
84
|
+
source = registry.for_url(result.url)
|
|
85
|
+
return source.name if source else "unknown"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _in_tests_or_ci() -> bool:
|
|
89
|
+
return "pytest" in sys.modules or any(
|
|
90
|
+
os.environ.get(v) for v in ("PYTEST_VERSION", "PYTEST_CURRENT_TEST", "CI")
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def level() -> str:
|
|
95
|
+
"""`off` under DO_NOT_TRACK, the test suite and CI, whatever the config says."""
|
|
96
|
+
if os.environ.get("DO_NOT_TRACK", "") not in ("", "0") or _in_tests_or_ci():
|
|
97
|
+
return "off"
|
|
98
|
+
return paths.telemetry()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@cache
|
|
102
|
+
def origin() -> tuple[str, str | None]:
|
|
103
|
+
"""`dev` and HEAD for an editable install (HEAD only on a clean tree), else `user`."""
|
|
104
|
+
try:
|
|
105
|
+
direct = json.loads(distribution("atsfill").read_text("direct_url.json") or "{}")
|
|
106
|
+
except PackageNotFoundError, json.JSONDecodeError:
|
|
107
|
+
direct = {}
|
|
108
|
+
if not direct.get("dir_info", {}).get("editable"):
|
|
109
|
+
return "user", None
|
|
110
|
+
try:
|
|
111
|
+
git = lambda *a: subprocess.run( # noqa: E731
|
|
112
|
+
["git", *a], capture_output=True, text=True, cwd=ROOT, timeout=GIT_TIMEOUT_SECONDS
|
|
113
|
+
).stdout.strip()
|
|
114
|
+
return "dev", None if git("status", "--porcelain") else git("rev-parse", "HEAD") or None
|
|
115
|
+
except OSError, subprocess.SubprocessError:
|
|
116
|
+
return "dev", None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def event(command: str, result: Result) -> dict:
|
|
120
|
+
source, commit = origin()
|
|
121
|
+
properties = {
|
|
122
|
+
"system": _system(result),
|
|
123
|
+
"command": command,
|
|
124
|
+
"verdict": verdict(result),
|
|
125
|
+
"status": result.status.value,
|
|
126
|
+
"reason": _code(result.reason),
|
|
127
|
+
"version": version("atsfill"),
|
|
128
|
+
"posting": posting_hash(_posting(result)),
|
|
129
|
+
"source": source,
|
|
130
|
+
"commit": commit,
|
|
131
|
+
}
|
|
132
|
+
return {k: v for k, v in properties.items() if v is not None}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _capture(properties: dict) -> None:
|
|
136
|
+
client = Posthog(
|
|
137
|
+
PROJECT_KEY,
|
|
138
|
+
host=HOST,
|
|
139
|
+
timeout=REQUEST_TIMEOUT_SECONDS,
|
|
140
|
+
max_retries=MAX_RETRIES,
|
|
141
|
+
disable_geoip=True,
|
|
142
|
+
)
|
|
143
|
+
properties = {**properties, "$process_person_profile": False}
|
|
144
|
+
client.capture("run", distinct_id=str(uuid.uuid4()), properties=properties)
|
|
145
|
+
client.flush(timeout_seconds=FLUSH_TIMEOUT_SECONDS)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _notice(data: Path) -> str | None:
|
|
149
|
+
"""The notice, the first time telemetry is on for this data directory."""
|
|
150
|
+
marker = data / NOTICE_FILE
|
|
151
|
+
if marker.exists():
|
|
152
|
+
return None
|
|
153
|
+
marker.touch()
|
|
154
|
+
return NOTICE.format(config=paths.config_file())
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def sent(command: str, result: Result, data: Path) -> Result:
|
|
158
|
+
"""The result, its event sent at `basic` and `detailed`."""
|
|
159
|
+
with suppress(Exception): # never in the way
|
|
160
|
+
if level() != "off":
|
|
161
|
+
result.notice = _notice(data)
|
|
162
|
+
_capture(event(command, result))
|
|
163
|
+
return result
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
# hosting hosted, embedded: where the form is found
|
|
8
8
|
# links_to an aggregator's: the sources its postings were followed to
|
|
9
9
|
# adapter the adapter's name, when there is one
|
|
10
|
+
# hosts a source without an adapter: the hosts its postings are on, subdomains included,
|
|
11
|
+
# so a run with no adapter still names its source
|
|
10
12
|
# block vendor, signal, step (posting | form | upload), date, version, camoufox, browser
|
|
11
13
|
# scope is (ADR 0016), `in_scope` when absent:
|
|
12
14
|
# supported date, commit: a dev dry run passed the adapter gate, never demoted
|
|
@@ -58,6 +60,7 @@ kind = "board"
|
|
|
58
60
|
share = 0.1006
|
|
59
61
|
account = "to_read"
|
|
60
62
|
hosting = ["hosted"]
|
|
63
|
+
hosts = ["trudvsem.ru"]
|
|
61
64
|
block = { vendor = "unknown", signal = "empty response to the browser, http 460 to curl, from outside Russia", step = "posting", date = 2026-10-20, version = "0.0.2", camoufox = "0.5.8", browser = "156.0.1-beta.36" }
|
|
62
65
|
|
|
63
66
|
[oracle]
|
|
@@ -97,6 +100,7 @@ kind = "ats"
|
|
|
97
100
|
share = 0.0244
|
|
98
101
|
account = "none"
|
|
99
102
|
hosting = ["hosted"]
|
|
103
|
+
hosts = ["paycomonline.net"]
|
|
100
104
|
block = { vendor = "hCaptcha", signal = "image challenge on Continue To Application, the step before the form, 3 of 3 employers headless, and again on a virtual display and headed", step = "form", date = 2026-10-20, version = "0.0.3", camoufox = "0.5.8", browser = "156.0.1-beta.36" }
|
|
101
105
|
|
|
102
106
|
[icims]
|
|
@@ -104,6 +108,7 @@ kind = "ats"
|
|
|
104
108
|
share = 0.0190
|
|
105
109
|
account = "to_read"
|
|
106
110
|
hosting = ["hosted"]
|
|
111
|
+
hosts = ["icims.com"]
|
|
107
112
|
block = { vendor = "hCaptcha", signal = "image challenge on the email step that starts sign-in and sign-up, before the form, 4 employers headless, Next disabled on 2 more", step = "form", date = 2026-10-26, version = "0.0.3", camoufox = "0.5.8", browser = "156.0.1-beta.36" }
|
|
108
113
|
|
|
109
114
|
[manatal]
|
|
@@ -149,6 +154,7 @@ kind = "board"
|
|
|
149
154
|
share = 0.0157
|
|
150
155
|
account = "to_read" # Log in to Apply goes to Singpass, Singapore's national digital identity; there is no email sign-up
|
|
151
156
|
hosting = ["hosted"]
|
|
157
|
+
hosts = ["mycareersfuture.gov.sg"]
|
|
152
158
|
scope = { is = "in_scope", date = 2026-10-26, reason = "unsupported_flow: national_id_login: applying needs a Singpass sign-in, whose account takes a Singapore NRIC or FIN number; no account can be made for the Test user (7 of 7 employers)" }
|
|
153
159
|
|
|
154
160
|
[adzuna]
|
|
@@ -169,6 +175,7 @@ kind = "ats"
|
|
|
169
175
|
share = 0.0125
|
|
170
176
|
account = "to_read"
|
|
171
177
|
hosting = ["hosted"]
|
|
178
|
+
hosts = ["taleo.net"]
|
|
172
179
|
scope = { is = "in_scope", date = 2026-10-26, reason = "unsupported_rendering: location_levels: a place's state and city list their options only once the level above is picked, so a fresh profile's are not listed (3 of 4 zones walked)" }
|
|
173
180
|
|
|
174
181
|
[jazzhr]
|
|
@@ -190,9 +197,10 @@ scope = { is = "supported", date = 2026-10-20, commit = "d35da0b" }
|
|
|
190
197
|
[adp]
|
|
191
198
|
kind = "ats"
|
|
192
199
|
share = 0.0108
|
|
193
|
-
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
|
|
194
201
|
hosting = ["hosted"]
|
|
195
|
-
|
|
202
|
+
hosts = ["workforcenow.adp.com"]
|
|
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)" }
|
|
196
204
|
|
|
197
205
|
[eures]
|
|
198
206
|
kind = "aggregator"
|
|
@@ -228,14 +236,16 @@ kind = "ats"
|
|
|
228
236
|
share = 0.0085
|
|
229
237
|
account = "to_read" # the sign-in's Create a new account sets a password, asking the name, country and zip code
|
|
230
238
|
hosting = ["hosted"]
|
|
239
|
+
hosts = ["workforceready.com", "yourpayrollhr.com", "saashr.com", "entertimeonline.com"]
|
|
231
240
|
scope = { is = "in_scope", date = 2026-10-26, reason = "unsupported_flow: profile_wizard: signed in, the form is the applicant portal's profile wizard, items of a dozen types edited in cards, entry lists, a security question and an electronic signature; not built within the round (1 tenant walked)" }
|
|
232
241
|
|
|
233
242
|
[adpmyjobs]
|
|
234
243
|
kind = "ats"
|
|
235
244
|
share = 0.0084
|
|
236
|
-
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
|
|
237
246
|
hosting = ["hosted"]
|
|
238
|
-
|
|
247
|
+
hosts = ["myjobs.adp.com"]
|
|
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)" }
|
|
239
249
|
|
|
240
250
|
[phenom]
|
|
241
251
|
kind = "ats"
|
|
@@ -266,6 +276,7 @@ kind = "ats"
|
|
|
266
276
|
share = 0.0059
|
|
267
277
|
account = "to_read"
|
|
268
278
|
hosting = ["hosted"]
|
|
279
|
+
hosts = ["hrmdirect.com"]
|
|
269
280
|
scope = { is = "in_scope", date = 2026-10-26, reason = "unsupported_flow: screening_first: the form's first page comes before the sign-up step (4 of 14 orgs); a reCAPTCHA box on the sign-up step (1 of 17); 3 orgs signed in, under the 5 dry runs the gate needs" }
|
|
270
281
|
|
|
271
282
|
[applicantpro]
|
|
@@ -273,6 +284,7 @@ kind = "ats"
|
|
|
273
284
|
share = 0.0057
|
|
274
285
|
account = "none"
|
|
275
286
|
hosting = ["hosted"]
|
|
287
|
+
hosts = ["applicantpro.com"]
|
|
276
288
|
|
|
277
289
|
[rippling]
|
|
278
290
|
kind = "ats"
|
|
@@ -293,9 +305,10 @@ scope = { is = "supported", date = 2026-10-26, commit = "867de7d" }
|
|
|
293
305
|
[jobstreet]
|
|
294
306
|
kind = "board"
|
|
295
307
|
share = 0.0047
|
|
296
|
-
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
|
|
297
309
|
hosting = ["hosted"]
|
|
298
|
-
|
|
310
|
+
hosts = ["jobstreet.com", "jobsdb.com"]
|
|
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)" }
|
|
299
312
|
|
|
300
313
|
[whatjobs-uk]
|
|
301
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
|