atsfill 0.0.3.dev6__tar.gz → 0.0.3.dev7__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.3.dev6 → atsfill-0.0.3.dev7}/PKG-INFO +2 -2
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/README.md +1 -1
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/pyproject.toml +1 -1
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/pyproject.toml.orig +1 -1
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/src/atsfill/adapters/ashby.py +43 -16
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/src/atsfill/registry.toml +2 -2
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/LICENSE +0 -0
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev7}/src/atsfill/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: atsfill
|
|
3
|
-
Version: 0.0.3.
|
|
3
|
+
Version: 0.0.3.dev7
|
|
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
|
|
@@ -53,7 +53,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
53
53
|
## Supported hiring systems
|
|
54
54
|
|
|
55
55
|
<!-- badges -->
|
|
56
|
-
   
|
|
57
57
|
<!-- /badges -->
|
|
58
58
|
|
|
59
59
|
| System | Account needed | `read` without a browser |
|
|
@@ -29,7 +29,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
29
29
|
## Supported hiring systems
|
|
30
30
|
|
|
31
31
|
<!-- badges -->
|
|
32
|
-
   
|
|
33
33
|
<!-- /badges -->
|
|
34
34
|
|
|
35
35
|
| System | Account needed | `read` without a browser |
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
"""Ashby (`jobs.ashbyhq.com`, and career pages embedding it), as docs/adapters/ashby.md observes.
|
|
2
2
|
|
|
3
|
-
Every change posts
|
|
4
|
-
|
|
3
|
+
Every change posts a mutation whose response holds the form as Ashby will receive it: that is
|
|
4
|
+
the read-back, kept per form since a survey answer returns the survey alone.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import json
|
|
8
8
|
import time
|
|
9
9
|
import urllib.request
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from functools import partial
|
|
10
12
|
from typing import Any
|
|
11
13
|
from urllib.parse import parse_qs, urlsplit
|
|
12
14
|
|
|
@@ -31,6 +33,7 @@ TYPES = {
|
|
|
31
33
|
"String": FieldType.TEXT,
|
|
32
34
|
"Email": FieldType.TEXT,
|
|
33
35
|
"LongText": FieldType.TEXT,
|
|
36
|
+
"Url": FieldType.TEXT,
|
|
34
37
|
"Date": FieldType.DATE,
|
|
35
38
|
"Number": FieldType.TEXT,
|
|
36
39
|
"Phone": FieldType.PHONE,
|
|
@@ -42,6 +45,7 @@ TYPES = {
|
|
|
42
45
|
}
|
|
43
46
|
SETTLE_S = 1.5
|
|
44
47
|
READ_BACK_TIMEOUT_S = 30
|
|
48
|
+
SAVE_TIMEOUT_MS = 30_000
|
|
45
49
|
SUBMIT_TIMEOUT_MS = 90_000
|
|
46
50
|
|
|
47
51
|
|
|
@@ -70,7 +74,7 @@ class Ashby:
|
|
|
70
74
|
|
|
71
75
|
def __init__(self) -> None:
|
|
72
76
|
self._forms: list[dict[str, Any]] = [] # the application form, then each survey
|
|
73
|
-
self._pending =
|
|
77
|
+
self._pending: set[Any] = set() # GraphQL requests in flight, since `read_form`
|
|
74
78
|
self._last_change = 0.0
|
|
75
79
|
self._hosted: str | None = None # the jobs.ashbyhq.com posting a custom domain embeds
|
|
76
80
|
|
|
@@ -132,6 +136,7 @@ class Ashby:
|
|
|
132
136
|
return [self._field(e) for e in self._entries()]
|
|
133
137
|
|
|
134
138
|
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
139
|
+
"""One save at a time: saves in flight together overwrite each other at Ashby."""
|
|
135
140
|
fields = {e["field"]["path"]: self._field(e) for e in self._entries()}
|
|
136
141
|
for field_id, answer in answers.items():
|
|
137
142
|
entry = page.locator(f'[data-field-path="{field_id}"]')
|
|
@@ -140,30 +145,49 @@ class Ashby:
|
|
|
140
145
|
case FieldType.TEXT | FieldType.DATE | FieldType.PHONE, str():
|
|
141
146
|
box = entry.locator("input, textarea").first
|
|
142
147
|
box.fill(answer)
|
|
143
|
-
box.blur
|
|
148
|
+
self._saved(page, box.blur)
|
|
149
|
+
case FieldType.CHOICE, str() if entry.locator("input[role=combobox]").count():
|
|
150
|
+
entry.locator("input[role=combobox]").fill(answer)
|
|
151
|
+
option = page.get_by_role("option", name=answer, exact=True)
|
|
152
|
+
self._saved(page, option.click)
|
|
144
153
|
case FieldType.CHOICE, str():
|
|
145
|
-
entry.get_by_text(answer, exact=True).click
|
|
154
|
+
self._saved(page, entry.get_by_text(answer, exact=True).click)
|
|
146
155
|
case FieldType.MULTI_CHOICE, list():
|
|
147
156
|
for value in answer:
|
|
148
|
-
entry.get_by_text(value, exact=True).click
|
|
157
|
+
self._saved(page, entry.get_by_text(value, exact=True).click)
|
|
149
158
|
case FieldType.TYPEAHEAD, str():
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
159
|
+
entry.locator("input[role=combobox]").fill(answer)
|
|
160
|
+
option = page.get_by_role("option").filter(has_text=answer).first
|
|
161
|
+
self._saved(page, option.click)
|
|
153
162
|
case FieldType.FILE, str():
|
|
154
|
-
entry.locator("input[type=file]")
|
|
163
|
+
upload = entry.locator("input[type=file]")
|
|
164
|
+
self._saved(page, partial(upload.set_input_files, answer))
|
|
155
165
|
case kind, _:
|
|
156
166
|
raise Unsupported(
|
|
157
167
|
f"form_shape: cannot fill {field_id}, a {kind}, with {answer!r}"
|
|
158
168
|
)
|
|
159
169
|
self._answered = list(answers)
|
|
160
170
|
|
|
171
|
+
def _saved(self, page: Page, change: Callable[[], None]) -> None:
|
|
172
|
+
"""Makes the change, then waits for its save and any other request it set off. A
|
|
173
|
+
change Ashby does not save shows in the read-back."""
|
|
174
|
+
try:
|
|
175
|
+
with page.expect_response(
|
|
176
|
+
lambda r: _op(r).startswith("ApiSetFormValue"), timeout=SAVE_TIMEOUT_MS
|
|
177
|
+
):
|
|
178
|
+
change()
|
|
179
|
+
except Timeout:
|
|
180
|
+
return
|
|
181
|
+
deadline = time.monotonic() + SAVE_TIMEOUT_MS / 1000
|
|
182
|
+
while self._pending and time.monotonic() < deadline:
|
|
183
|
+
page.wait_for_timeout(100)
|
|
184
|
+
|
|
161
185
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
162
186
|
"""Ashby's last returned form, once every answer is held and no request is in flight."""
|
|
163
187
|
deadline = time.monotonic() + READ_BACK_TIMEOUT_S
|
|
164
188
|
while time.monotonic() < deadline:
|
|
165
189
|
values = self._values()
|
|
166
|
-
quiet = self._pending
|
|
190
|
+
quiet = not self._pending and time.monotonic() - self._last_change > SETTLE_S
|
|
167
191
|
if quiet and all(values.get(i) is not None for i in self._answered):
|
|
168
192
|
break
|
|
169
193
|
page.wait_for_timeout(250)
|
|
@@ -248,15 +272,18 @@ class Ashby:
|
|
|
248
272
|
|
|
249
273
|
def _on_request(self, request: Any) -> None:
|
|
250
274
|
if "/api/non-user-graphql" in request.url:
|
|
251
|
-
self._pending
|
|
275
|
+
self._pending.add(request)
|
|
252
276
|
|
|
253
277
|
def _on_finished(self, request: Any) -> None:
|
|
254
278
|
if "/api/non-user-graphql" in request.url:
|
|
255
|
-
self._pending
|
|
279
|
+
self._pending.discard(request)
|
|
256
280
|
self._last_change = time.monotonic()
|
|
257
281
|
|
|
258
282
|
def _on_response(self, response: Response) -> None:
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
283
|
+
"""Any response holding a form render: a value goes to `ApiSetFormValue`, a file to
|
|
284
|
+
`ApiSetFormValueToFile` once its upload ends, after saves made later."""
|
|
285
|
+
if not _op(response).startswith("ApiSetFormValue") or not response.ok:
|
|
286
|
+
return
|
|
287
|
+
for render in (response.json().get("data") or {}).values():
|
|
288
|
+
if isinstance(render, dict) and "sections" in render:
|
|
262
289
|
self._replace(render)
|
|
@@ -19,8 +19,8 @@ kind = "ats"
|
|
|
19
19
|
account = "none"
|
|
20
20
|
hosting = ["hosted", "embedded"]
|
|
21
21
|
adapter = "ashby"
|
|
22
|
-
send_verified = { date = 2026-10-
|
|
23
|
-
|
|
22
|
+
send_verified = { date = 2026-10-19, version = "0.0.2", by = "shbernal", commit = "7622376" }
|
|
23
|
+
dry_run_verified = { date = 2026-10-19, version = "0.0.2", by = "gate", commit = "c230c5e" }
|
|
24
24
|
|
|
25
25
|
[teamtailor]
|
|
26
26
|
kind = "ats"
|
|
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
|