atsfill 0.0.3.dev6__tar.gz → 0.0.3.dev8__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.dev8}/PKG-INFO +2 -2
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/README.md +1 -1
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/pyproject.toml +1 -1
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/pyproject.toml.orig +1 -1
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/src/atsfill/adapters/ashby.py +43 -16
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/src/atsfill/adapters/greenhouse.py +37 -46
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/src/atsfill/registry.toml +3 -2
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/LICENSE +0 -0
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.3.dev6 → atsfill-0.0.3.dev8}/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.dev8
|
|
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)
|
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
pages embedding them), as docs/adapters/greenhouse.md observes.
|
|
3
3
|
|
|
4
4
|
Nothing but an attached file reaches Greenhouse before submit, so the read-back is the page:
|
|
5
|
-
input values, the shown value of each select, the phone
|
|
5
|
+
input values, the shown value of each select, the phone's dial code, the attached file's name.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import json
|
|
9
9
|
import re
|
|
10
10
|
import time
|
|
11
11
|
import urllib.request
|
|
12
|
+
from contextlib import suppress
|
|
12
13
|
from typing import Any
|
|
13
14
|
from urllib.parse import parse_qs, urlencode, urlsplit
|
|
14
15
|
|
|
@@ -25,6 +26,7 @@ EMBED_TIMEOUT_MS = 15_000
|
|
|
25
26
|
OPTIONS_TIMEOUT_MS = 3_000
|
|
26
27
|
HYDRATE_TIMEOUT_MS = 15_000
|
|
27
28
|
FORM_TIMEOUT_MS = 20_000
|
|
29
|
+
ANSWER_TIMEOUT_MS = 15_000 # an upload's file name, a suggestion: past it, a mismatch
|
|
28
30
|
SUBMIT_TIMEOUT_MS = 90_000
|
|
29
31
|
API_TYPES = {
|
|
30
32
|
"input_text": FieldType.TEXT,
|
|
@@ -34,6 +36,9 @@ API_TYPES = {
|
|
|
34
36
|
"multi_value_multi_select": FieldType.MULTI_CHOICE,
|
|
35
37
|
}
|
|
36
38
|
YES_NO = ["Yes", "No"]
|
|
39
|
+
# Sections the page adds or names its own way, or shows a question of only on another answer
|
|
40
|
+
# (EEOC race after Hispanic/Latino): `read_api` declines a posting with any (ADR 0011).
|
|
41
|
+
UNMAPPED = ("education", "compliance", "demographic_questions")
|
|
37
42
|
|
|
38
43
|
# The fields in page order. A select's options are read by opening it, from Python.
|
|
39
44
|
_READ = """form => {
|
|
@@ -48,12 +53,14 @@ _READ = """form => {
|
|
|
48
53
|
const required = el => el.getAttribute("aria-required") === "true" || el.required;
|
|
49
54
|
const found = [];
|
|
50
55
|
for (const el of form.querySelectorAll("input, textarea")) {
|
|
51
|
-
if (!el.id || ["hidden", "search", "file", "checkbox"
|
|
56
|
+
if (!el.id || ["hidden", "search", "file", "checkbox"].includes(el.type)) continue;
|
|
52
57
|
const combo = el.getAttribute("role") === "combobox";
|
|
58
|
+
if (combo && el.closest(".phone-input")) continue; // the phone's country, set by its number
|
|
53
59
|
const shell = el.closest(".select");
|
|
54
60
|
const multi = combo && !!shell?.querySelector(".select__value-container--is-multi");
|
|
55
61
|
found.push([el, {id: el.id, label: text(labelFor(el)), required: required(el),
|
|
56
|
-
kind: combo ? (multi ? "multi" : "select")
|
|
62
|
+
kind: combo ? (multi ? "multi" : "select")
|
|
63
|
+
: ["tel", "number", "radio"].includes(el.type) ? el.type : "text",
|
|
57
64
|
maxLength: el.maxLength > 0 ? el.maxLength : null}]);
|
|
58
65
|
}
|
|
59
66
|
for (const group of form.querySelectorAll("fieldset.checkbox")) {
|
|
@@ -80,8 +87,8 @@ _READ = """form => {
|
|
|
80
87
|
return found.map(([, field]) => field);
|
|
81
88
|
}"""
|
|
82
89
|
|
|
83
|
-
# What each field shows: a select its shown value
|
|
84
|
-
# its
|
|
90
|
+
# What each field shows: a select its shown value, a file its attached name, a checkbox group
|
|
91
|
+
# its checked labels, the phone its country's dial code then its number.
|
|
85
92
|
_READ_BACK = """(form, ids) => {
|
|
86
93
|
const text = el => el ? el.textContent.replace(/\\s+/g, " ").trim() : null;
|
|
87
94
|
const out = {};
|
|
@@ -94,10 +101,12 @@ _READ_BACK = """(form, ids) => {
|
|
|
94
101
|
.map(b => text(document.querySelector(`label[for="${CSS.escape(b.id)}"]`)));
|
|
95
102
|
} else if (el?.getAttribute("role") === "combobox") {
|
|
96
103
|
const select = el.closest(".select");
|
|
97
|
-
const flag = select.querySelector(".select__single-value .iti__flag");
|
|
98
104
|
const multi = [...select.querySelectorAll(".select__multi-value__label")].map(text);
|
|
99
|
-
out[id] =
|
|
100
|
-
|
|
105
|
+
out[id] = multi.length ? multi : text(select.querySelector(".select__single-value"));
|
|
106
|
+
} else if (el?.type === "tel" && el.closest(".phone-input")) {
|
|
107
|
+
const dial = text(el.closest(".phone-input").querySelector(".select__single-value"));
|
|
108
|
+
const number = el.value.startsWith(dial) ? el.value.slice(dial.length).trim() : el.value;
|
|
109
|
+
out[id] = dial && number ? `${dial} ${number}` : null;
|
|
101
110
|
} else {
|
|
102
111
|
const box = form.querySelector(`input[type=checkbox][name="${id}"]`) ?? el;
|
|
103
112
|
out[id] = box?.type === "checkbox" ? (box.checked ? "Yes" : "No") : el?.value ?? null;
|
|
@@ -117,11 +126,11 @@ class Greenhouse:
|
|
|
117
126
|
def __init__(self) -> None:
|
|
118
127
|
self._fields: dict[str, Field] = {}
|
|
119
128
|
self._kinds: dict[str, str] = {} # how the page renders each field
|
|
120
|
-
self._flags: dict[str, dict[str, str]] = {} # per select, option text by flag code
|
|
121
129
|
self._embed: str | None = None # the job_app URL a company page embeds
|
|
122
130
|
|
|
123
131
|
def detect(self, url: str, page: Page | None) -> bool:
|
|
124
132
|
"""A board URL, or a company page whose `#grnhse_iframe` embeds a job application."""
|
|
133
|
+
self._embed = None # one process detects many postings: a survey, the gate
|
|
125
134
|
if _board_job(url):
|
|
126
135
|
return True
|
|
127
136
|
if page is None:
|
|
@@ -181,7 +190,8 @@ class Greenhouse:
|
|
|
181
190
|
case "file":
|
|
182
191
|
upload = page.locator(_upload(field_id))
|
|
183
192
|
upload.locator("input[type=file]").set_input_files(values[0])
|
|
184
|
-
|
|
193
|
+
with suppress(Timeout):
|
|
194
|
+
upload.locator(".file-upload__filename").wait_for(timeout=ANSWER_TIMEOUT_MS)
|
|
185
195
|
case "consent":
|
|
186
196
|
box = page.locator(f"{FORM} input[type=checkbox][name='{field_id}']").first
|
|
187
197
|
if box.is_checked() != (answer == "Yes"):
|
|
@@ -194,15 +204,15 @@ class Greenhouse:
|
|
|
194
204
|
suggested = self._fields[field_id].type is FieldType.TYPEAHEAD
|
|
195
205
|
for value in values:
|
|
196
206
|
_select(page, field_id, value, suggested)
|
|
207
|
+
case "tel": # the number's dial code picks the country
|
|
208
|
+
page.locator(_by_id(field_id)).press_sequentially(values[0], delay=20)
|
|
209
|
+
case "number" if not re.fullmatch(r"-?\d+(\.\d+)?", values[0]):
|
|
210
|
+
pass # the input takes no text: left empty, it reads back as a mismatch
|
|
197
211
|
case _:
|
|
198
212
|
page.locator(_by_id(field_id)).fill(values[0])
|
|
199
213
|
|
|
200
214
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
201
215
|
values = page.locator(FORM).evaluate(_READ_BACK, list(self._fields))
|
|
202
|
-
for field_id, flags in self._flags.items():
|
|
203
|
-
shown = values.get(field_id)
|
|
204
|
-
if isinstance(shown, str) and shown.startswith("flag:"):
|
|
205
|
-
values[field_id] = flags.get(shown[5:], shown)
|
|
206
216
|
return {k: v for k, v in values.items() if v not in (None, "", [])}
|
|
207
217
|
|
|
208
218
|
def submit(self, page: Page) -> str | None:
|
|
@@ -213,7 +223,7 @@ class Greenhouse:
|
|
|
213
223
|
def _field(self, page: Page, raw: dict[str, Any]) -> Field:
|
|
214
224
|
kind, options = raw["kind"], raw.get("options")
|
|
215
225
|
match kind:
|
|
216
|
-
case "text":
|
|
226
|
+
case "text" | "number":
|
|
217
227
|
field_type = FieldType.TEXT
|
|
218
228
|
case "tel":
|
|
219
229
|
field_type = FieldType.PHONE
|
|
@@ -229,7 +239,7 @@ class Greenhouse:
|
|
|
229
239
|
field_type = FieldType.TYPEAHEAD
|
|
230
240
|
else:
|
|
231
241
|
field_type = FieldType.MULTI_CHOICE if kind == "multi" else FieldType.CHOICE
|
|
232
|
-
case _:
|
|
242
|
+
case _: # a radio input, not seen on a posting yet
|
|
233
243
|
raise Unsupported(f"form_shape: {raw['id']} is a {kind}")
|
|
234
244
|
return Field(
|
|
235
245
|
raw["id"],
|
|
@@ -252,12 +262,6 @@ class Greenhouse:
|
|
|
252
262
|
box.press("Escape")
|
|
253
263
|
return None
|
|
254
264
|
options = [t.strip() for t in menu.all_inner_texts()]
|
|
255
|
-
flags = menu.evaluate_all(
|
|
256
|
-
"items => items.map(i => [...(i.querySelector('.iti__flag')?.classList ?? [])]"
|
|
257
|
-
".find(c => /^iti__[a-z]{2}$/.test(c))?.slice(5))"
|
|
258
|
-
)
|
|
259
|
-
if any(flags):
|
|
260
|
-
self._flags[field_id] = {f: o for f, o in zip(flags, options, strict=True) if f}
|
|
261
265
|
box.press("Escape")
|
|
262
266
|
return options
|
|
263
267
|
|
|
@@ -294,39 +298,22 @@ def _board_job(url: str) -> tuple[str, str] | None:
|
|
|
294
298
|
|
|
295
299
|
|
|
296
300
|
def _api_fields(job: dict[str, Any]) -> list[Field] | None:
|
|
297
|
-
if job.get(
|
|
301
|
+
if any(job.get(k) for k in UNMAPPED) or any(
|
|
298
302
|
c.get("requires_consent") or c.get("requires_processing_consent")
|
|
299
303
|
for c in job.get("data_compliance") or []
|
|
300
304
|
):
|
|
301
|
-
return None
|
|
305
|
+
return None
|
|
302
306
|
fields = []
|
|
303
307
|
for question in job["questions"] + (job.get("location_questions") or []):
|
|
304
308
|
entry = question["fields"][0] # a file's paste-in textarea comes second
|
|
305
|
-
if entry["type"] == "input_hidden":
|
|
309
|
+
if entry["type"] == "input_hidden": # a location's latitude and longitude
|
|
306
310
|
continue
|
|
307
311
|
name = entry["name"]
|
|
308
|
-
field_type = API_TYPES[entry["type"]]
|
|
309
|
-
options = [v["label"] for v in entry.get("values") or []] or None
|
|
310
|
-
if name == "phone":
|
|
311
|
-
fields.append(Field("country", "Country", FieldType.CHOICE, question["required"]))
|
|
312
|
-
field_type = FieldType.PHONE
|
|
312
|
+
field_type = FieldType.PHONE if name == "phone" else API_TYPES[entry["type"]]
|
|
313
313
|
if name == "location":
|
|
314
314
|
name, field_type = "candidate-location", FieldType.TYPEAHEAD
|
|
315
|
+
options = [v["label"] for v in entry.get("values") or []] or None
|
|
315
316
|
fields.append(Field(name, question["label"], field_type, question["required"], options))
|
|
316
|
-
for section in job.get("compliance") or []:
|
|
317
|
-
for question in section["questions"]:
|
|
318
|
-
entry = question["fields"][0]
|
|
319
|
-
options = [v["label"] for v in entry.get("values") or []] or None
|
|
320
|
-
kind = API_TYPES[entry["type"]]
|
|
321
|
-
fields.append(
|
|
322
|
-
Field(entry["name"], question["label"], kind, question["required"], options)
|
|
323
|
-
)
|
|
324
|
-
for question in (job.get("demographic_questions") or {}).get("questions", []):
|
|
325
|
-
options = [o["label"] for o in question["answer_options"]]
|
|
326
|
-
kind = API_TYPES[question["type"]]
|
|
327
|
-
fields.append(
|
|
328
|
-
Field(str(question["id"]), question["label"], kind, question["required"], options)
|
|
329
|
-
)
|
|
330
317
|
return fields
|
|
331
318
|
|
|
332
319
|
|
|
@@ -345,12 +332,16 @@ def _exact(value: str) -> re.Pattern[str]:
|
|
|
345
332
|
|
|
346
333
|
def _select(page: Page, field_id: str, value: str, suggested: bool) -> None:
|
|
347
334
|
"""Types into a react-select and picks the option: hovered then Enter, which every select
|
|
348
|
-
takes
|
|
335
|
+
takes. A suggestion list takes its first suggestion. No option leaves the select empty."""
|
|
349
336
|
box = page.locator(_by_id(field_id))
|
|
350
337
|
box.click()
|
|
351
338
|
box.press_sequentially(value, delay=20)
|
|
352
339
|
menu = page.locator(f"{_by_id(f'react-select-{field_id}-listbox')} [role=option]")
|
|
353
340
|
option = menu.first if suggested else menu.filter(has_text=_exact(value)).first
|
|
354
|
-
|
|
341
|
+
try:
|
|
342
|
+
option.wait_for(timeout=ANSWER_TIMEOUT_MS)
|
|
343
|
+
except Timeout:
|
|
344
|
+
box.press("Escape")
|
|
345
|
+
return
|
|
355
346
|
option.hover()
|
|
356
347
|
box.press("Enter")
|
|
@@ -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"
|
|
@@ -33,6 +33,7 @@ kind = "ats"
|
|
|
33
33
|
account = "none"
|
|
34
34
|
hosting = ["hosted", "embedded"]
|
|
35
35
|
adapter = "greenhouse"
|
|
36
|
+
dry_run_verified = { date = 2026-10-19, version = "0.0.2", by = "gate", commit = "c0fb0c5" }
|
|
36
37
|
|
|
37
38
|
[wttj]
|
|
38
39
|
kind = "board"
|
|
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
|