atsfill 0.0.5.dev141__tar.gz → 0.0.5.dev142__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.
Files changed (58) hide show
  1. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/PKG-INFO +4 -1
  2. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/README.md +2 -0
  3. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/pyproject.toml +2 -1
  4. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/pyproject.toml.orig +2 -1
  5. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/cli.py +2 -0
  6. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/core/commands.py +10 -3
  7. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/core/paths.py +8 -0
  8. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/core/registry.py +10 -0
  9. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/core/results.py +1 -0
  10. atsfill-0.0.5.dev142/src/atsfill/core/telemetry.py +163 -0
  11. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/registry.toml +13 -0
  12. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/LICENSE +0 -0
  13. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/__init__.py +0 -0
  14. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/adapters/__init__.py +0 -0
  15. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/adapters/ashby.py +0 -0
  16. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/adapters/bamboohr.py +0 -0
  17. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/adapters/breezy.py +0 -0
  18. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/adapters/dayforce.py +0 -0
  19. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/adapters/eightfold.py +0 -0
  20. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/adapters/greenhouse.py +0 -0
  21. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/adapters/gupy.py +0 -0
  22. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/adapters/hireology.py +0 -0
  23. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/adapters/hiringthing.py +0 -0
  24. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/adapters/hrmdirect.py +0 -0
  25. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/adapters/isolvedhire.py +0 -0
  26. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/adapters/jazzhr.py +0 -0
  27. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/adapters/join.py +0 -0
  28. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/adapters/lever.py +0 -0
  29. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/adapters/manatal.py +0 -0
  30. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/adapters/oracle.py +0 -0
  31. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/adapters/paycor.py +0 -0
  32. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/adapters/paylocity.py +0 -0
  33. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/adapters/personio.py +0 -0
  34. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/adapters/phenom.py +0 -0
  35. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/adapters/pinpoint.py +0 -0
  36. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/adapters/recruitee.py +0 -0
  37. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/adapters/rippling.py +0 -0
  38. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/adapters/smartrecruiters.py +0 -0
  39. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/adapters/successfactors.js +0 -0
  40. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/adapters/successfactors.py +0 -0
  41. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/adapters/taleo.py +0 -0
  42. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/adapters/teamtailor.py +0 -0
  43. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/adapters/ukg.py +0 -0
  44. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/adapters/workable.py +0 -0
  45. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/adapters/workday.py +0 -0
  46. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/adapters/wttj.py +0 -0
  47. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/adapters/zohorecruit.py +0 -0
  48. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/core/__init__.py +0 -0
  49. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/core/accounts.py +0 -0
  50. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/core/adapter.py +0 -0
  51. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/core/attempts.py +0 -0
  52. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/core/browser.py +0 -0
  53. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/core/conditions.py +0 -0
  54. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/core/html.py +0 -0
  55. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/core/matching.py +0 -0
  56. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/core/otp.py +0 -0
  57. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/src/atsfill/core/passwords.py +0 -0
  58. {atsfill-0.0.5.dev141 → atsfill-0.0.5.dev142}/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.dev141
3
+ Version: 0.0.5.dev142
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.dev141"
3
+ version = "0.0.5.dev142"
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.dev141"
3
+ version = "0.0.5.dev142"
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
- return _in_budget(
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
- return _in_budget(
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]
@@ -192,6 +199,7 @@ kind = "ats"
192
199
  share = 0.0108
193
200
  account = "to_read" # Apply opens a talent community step, name, email and mobile number, verified with a one-time code before the form
194
201
  hosting = ["hosted"]
202
+ hosts = ["workforcenow.adp.com"]
195
203
  scope = { is = "in_scope", date = 2026-10-26, reason = "unsupported_flow: sign_up_otp: the talent community step before the form sends a one-time code to the email or mobile number, on every employer that showed it (14 of 15 employers)" }
196
204
 
197
205
  [eures]
@@ -228,6 +236,7 @@ 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]
@@ -235,6 +244,7 @@ kind = "ats"
235
244
  share = 0.0084
236
245
  account = "to_read" # the posting loads only signed in; the sign-in has no password, a one-time code each time
237
246
  hosting = ["hosted"]
247
+ hosts = ["myjobs.adp.com"]
238
248
  scope = { is = "in_scope", date = 2026-10-26, reason = "unsupported_flow: sign_in_otp: every sign-in, the sign-up's included, is a one-time code sent to the email or phone, and the posting loads only signed in (11 of 11 employers)" }
239
249
 
240
250
  [phenom]
@@ -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"
@@ -295,6 +307,7 @@ kind = "board"
295
307
  share = 0.0047
296
308
  account = "to_read" # Apply needs a SEEK account; its sign-in and sign-up have no password, an emailed one-time code each time
297
309
  hosting = ["hosted"]
310
+ hosts = ["jobstreet.com", "jobsdb.com"]
298
311
  scope = { is = "in_scope", date = 2026-10-26, reason = "unsupported_flow: sign_in_otp: Apply goes to the SEEK sign-in, whose every sign-in and sign-up is a six-digit code emailed to the address, with no password (14 of 15 employers)" }
299
312
 
300
313
  [whatjobs-uk]
File without changes