atsfill 0.0.3.dev11__tar.gz → 0.0.3.dev13__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.dev11 → atsfill-0.0.3.dev13}/PKG-INFO +2 -2
- {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/README.md +1 -1
- {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/pyproject.toml +1 -1
- {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/pyproject.toml.orig +1 -1
- {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/src/atsfill/adapters/teamtailor.py +147 -47
- {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/src/atsfill/registry.toml +1 -0
- {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/LICENSE +0 -0
- {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.3.dev11 → atsfill-0.0.3.dev13}/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.dev13
|
|
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 |
|
|
@@ -10,19 +10,27 @@ import time
|
|
|
10
10
|
from typing import Any
|
|
11
11
|
from urllib.parse import urlsplit
|
|
12
12
|
|
|
13
|
-
from atsfill.core import browser
|
|
13
|
+
from atsfill.core import browser, matching
|
|
14
14
|
from atsfill.core.adapter import Unsupported
|
|
15
15
|
from atsfill.core.browser import Locator, Page
|
|
16
16
|
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
17
17
|
|
|
18
18
|
FORM = "#job-application-form"
|
|
19
|
+
# A custom question, or a qualifying one: the rest of the form stays disabled until it is
|
|
20
|
+
# answered with the answer the employer requires.
|
|
21
|
+
QUESTION = ".question, [data-careersite--form-target=qualifyingAnswers]"
|
|
22
|
+
LOCKED = "[data-careersite--form-target=formContentWrapper].cursor-not-allowed"
|
|
23
|
+
LOCATION = "candidate[location][query]"
|
|
24
|
+
CAREER_SITE = "a[href*='//www.teamtailor.com'], script[src*='teamtailor-cdn.com/assets/careersite']"
|
|
19
25
|
JOB = re.compile(r"/jobs/(\d+)[^/]*")
|
|
20
26
|
SUBMIT_TIMEOUT_MS = 90_000
|
|
27
|
+
FOLLOW_UP_TIMEOUT_MS = 1_000
|
|
21
28
|
UPLOAD_TIMEOUT_S = 60
|
|
22
29
|
QUESTION_TYPES = {
|
|
23
30
|
"text": FieldType.TEXT,
|
|
24
31
|
"number": FieldType.TEXT,
|
|
25
32
|
"date": FieldType.DATE,
|
|
33
|
+
"range": FieldType.CHOICE,
|
|
26
34
|
"boolean": FieldType.CHOICE,
|
|
27
35
|
"choice": FieldType.CHOICE,
|
|
28
36
|
"choices": FieldType.MULTI_CHOICE,
|
|
@@ -32,14 +40,16 @@ STANDARD_TYPES = {
|
|
|
32
40
|
"email": FieldType.TEXT,
|
|
33
41
|
"textarea": FieldType.TEXT,
|
|
34
42
|
"tel": FieldType.PHONE,
|
|
43
|
+
"location": FieldType.TYPEAHEAD,
|
|
35
44
|
"checkbox": FieldType.CHOICE,
|
|
45
|
+
"choices": FieldType.MULTI_CHOICE,
|
|
36
46
|
"file": FieldType.FILE,
|
|
37
47
|
}
|
|
38
48
|
CHECKBOX = ["Yes", "No"]
|
|
39
49
|
|
|
40
50
|
# Every field of the form, described from the page. A custom question's id is
|
|
41
51
|
# `question:<Teamtailor question id>`; the others are the input's name.
|
|
42
|
-
_READ = """form => {
|
|
52
|
+
_READ = """(form, QUESTION) => {
|
|
43
53
|
const label = el => {
|
|
44
54
|
if (!el) return "";
|
|
45
55
|
const copy = el.cloneNode(true);
|
|
@@ -47,45 +57,68 @@ _READ = """form => {
|
|
|
47
57
|
return copy.textContent.replace(/\\s+/g, " ").trim();
|
|
48
58
|
};
|
|
49
59
|
const required = el => !!el?.querySelector("sup[data-asterisk]");
|
|
60
|
+
const PICKS = "input[type=radio], input[type=checkbox]";
|
|
61
|
+
const options = (box, inputs) => {
|
|
62
|
+
const item = value => box.querySelector(`[role^=menuitem][data-value="${value}"]`);
|
|
63
|
+
return [...inputs]
|
|
64
|
+
.map(e => label(e.labels?.[0]) || item(e.value)?.dataset.searchText || e.value);
|
|
65
|
+
};
|
|
50
66
|
const fields = [];
|
|
51
67
|
for (const el of form.querySelectorAll("input, textarea")) {
|
|
52
|
-
if (el.closest(
|
|
53
|
-
const kind = el.tagName === "TEXTAREA" ? "textarea"
|
|
68
|
+
if (el.closest(QUESTION) || el.disabled || fields.some(f => f.id === el.name)) continue;
|
|
69
|
+
const kind = el.tagName === "TEXTAREA" ? "textarea"
|
|
70
|
+
: el.name === "candidate[location][query]" ? "location"
|
|
71
|
+
: el.type === "checkbox" && el.name.endsWith("[]") ? "choices" : el.type;
|
|
54
72
|
let id = el.name;
|
|
55
73
|
if (kind === "file") id = `candidate[${el.id.replace(/^candidate_/, "")}]`;
|
|
56
|
-
if (!["text", "email", "tel", "textarea", "checkbox", "file"]
|
|
74
|
+
if (!["text", "email", "tel", "textarea", "checkbox", "choices", "file", "location"]
|
|
75
|
+
.includes(kind) || !id) continue;
|
|
57
76
|
if (/linkedin|referrer|social_image/.test(id)) continue;
|
|
58
|
-
const
|
|
77
|
+
const box = kind === "choices" ? el.closest("fieldset") : null;
|
|
78
|
+
const host = box ? box.querySelector("legend")
|
|
79
|
+
: el.labels?.[0] ?? el.closest("[data-controller]")?.querySelector("label");
|
|
59
80
|
fields.push({id, kind, label: label(host), required: required(host),
|
|
81
|
+
options: box ? options(box, form.querySelectorAll(`[name="${id}"]`)) : null,
|
|
60
82
|
maxLength: el.maxLength > 0 ? el.maxLength : null,
|
|
61
83
|
accept: el.accept ? el.accept.split(",") : null});
|
|
62
84
|
}
|
|
63
|
-
for (const q of form.querySelectorAll(
|
|
85
|
+
for (const q of form.querySelectorAll(QUESTION)) {
|
|
64
86
|
const qid = q.querySelector("input[name$='[question_id]']")?.value;
|
|
87
|
+
const qualifying = !q.matches(".question");
|
|
65
88
|
const named = q.querySelector("[name^='candidate[answers_attributes]']:not([type=hidden])");
|
|
66
89
|
const kind = /picked_video/.test(named?.name) ? "video"
|
|
67
90
|
: q.querySelector("input[type=file]") && !named ? "file"
|
|
68
91
|
: (named?.name.match(/\\]\\[(\\w+)\\](\\[\\])?$/)?.[1] ?? "unknown");
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
92
|
+
const picks = options(q, q.querySelectorAll(PICKS));
|
|
93
|
+
if (named?.type === "range") {
|
|
94
|
+
const step = Number(named.step) || 1;
|
|
95
|
+
for (let v = Number(named.min); v <= Number(named.max); v += step) picks.push(String(v));
|
|
96
|
+
}
|
|
72
97
|
const menu = [...q.querySelectorAll("[role^=menuitem]")].map(e => e.dataset.searchText);
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
98
|
+
const parent = q.dataset.questionShowIfUuid
|
|
99
|
+
&& form.querySelector(`[data-question-uuid="${q.dataset.questionShowIfUuid}"]`);
|
|
100
|
+
const shownBy = parent && [...parent.querySelectorAll(PICKS)]
|
|
101
|
+
.find(e => e.value === q.dataset.questionShowIfValue);
|
|
102
|
+
const when = shownBy && ` (asked when "${label(parent.querySelector("legend, label"))}" is`
|
|
103
|
+
+ ` "${options(parent, [shownBy])[0]}")`;
|
|
104
|
+
fields.push({id: `question:${qid}`, kind, question: true, qualifying, conditional: !!parent,
|
|
105
|
+
label: label(q.querySelector("legend, label")) + (when || ""),
|
|
106
|
+
required: qualifying || (!parent && q.dataset.questionMandatory === "true"),
|
|
107
|
+
asked: q.dataset.questionMandatory === "true",
|
|
108
|
+
options: picks.length ? picks : menu.length ? menu : null});
|
|
77
109
|
}
|
|
78
110
|
return fields;
|
|
79
111
|
}"""
|
|
80
112
|
|
|
81
113
|
# What the form will send: each field's value, a choice as its option's text, a file as the
|
|
82
|
-
# uploaded file's name.
|
|
83
|
-
_READ_BACK = """form => {
|
|
114
|
+
# uploaded file's name, an address once a suggestion is picked.
|
|
115
|
+
_READ_BACK = """(form, QUESTION) => {
|
|
84
116
|
const data = new FormData(form);
|
|
85
117
|
const text = e => {
|
|
86
118
|
const label = e.labels?.[0];
|
|
87
119
|
if (label) return label.textContent.replace(/\\s+/g, " ").trim();
|
|
88
|
-
const
|
|
120
|
+
const box = e.closest(`${QUESTION}, fieldset`);
|
|
121
|
+
const item = box?.querySelector(`[role^=menuitem][data-value="${e.value}"]`);
|
|
89
122
|
return item?.dataset.searchText ?? e.value;
|
|
90
123
|
};
|
|
91
124
|
const file = url => url ? decodeURIComponent(new URL(url).pathname.split("/").pop()) : null;
|
|
@@ -93,14 +126,22 @@ _READ_BACK = """form => {
|
|
|
93
126
|
for (const el of form.querySelectorAll("input[type=checkbox][name^='candidate['][value='1']")) {
|
|
94
127
|
out[el.name] = data.getAll(el.name).includes("1") ? "Yes" : "No";
|
|
95
128
|
}
|
|
129
|
+
for (const el of form.querySelectorAll("input[type=checkbox][name^='candidate['][name$='[]']")) {
|
|
130
|
+
out[el.name] = [...form.querySelectorAll(`[name="${el.name}"]:checked`)].map(text);
|
|
131
|
+
}
|
|
96
132
|
for (const name of ["first_name", "last_name", "email", "phone"]) {
|
|
97
133
|
out[`candidate[${name}]`] = data.get(`candidate[${name}]`);
|
|
98
134
|
}
|
|
99
135
|
for (const [name, value] of data) {
|
|
100
136
|
if (/^candidate\\[\\w+_remote_url\\]$/.test(name)) out[name] = file(value);
|
|
101
|
-
else if (
|
|
137
|
+
else if (/^candidate\\[uploads_attributes\\]\\[\\d+\\]\\[file_remote_url\\]$/.test(name)) {
|
|
138
|
+
out["candidate[file_remote_url]"] = file(value); // "Additional files", one per upload
|
|
139
|
+
} else if (/cover_letter/.test(name)) out[name] = value;
|
|
140
|
+
}
|
|
141
|
+
if (data.get("candidate[location][place_id]")) {
|
|
142
|
+
out["candidate[location][query]"] = data.get("candidate[location][query]");
|
|
102
143
|
}
|
|
103
|
-
for (const q of form.querySelectorAll(
|
|
144
|
+
for (const q of form.querySelectorAll(QUESTION)) {
|
|
104
145
|
const id = `question:${q.querySelector("input[name$='[question_id]']")?.value}`;
|
|
105
146
|
const checked = [...q.querySelectorAll("input:checked")];
|
|
106
147
|
const named = q.querySelector("[name^='candidate[answers_attributes]']:not([type=hidden])");
|
|
@@ -121,14 +162,17 @@ class Teamtailor:
|
|
|
121
162
|
|
|
122
163
|
def __init__(self) -> None:
|
|
123
164
|
self._fields: dict[str, Field] = {}
|
|
165
|
+
self._qualifying: set[str] = set()
|
|
166
|
+
self._follow_ups: dict[str, bool] = {} # shown by an earlier answer: required once shown
|
|
124
167
|
|
|
125
168
|
def detect(self, url: str, page: Page | None) -> bool:
|
|
126
|
-
"""A `*.teamtailor.com` job, or a job page with Teamtailor's "powered by" link
|
|
169
|
+
"""A `*.teamtailor.com` job, or a job page with Teamtailor's "powered by" link or its
|
|
170
|
+
career site script."""
|
|
127
171
|
if not JOB.search(urlsplit(url).path):
|
|
128
172
|
return False
|
|
129
173
|
if (urlsplit(url).hostname or "").endswith(".teamtailor.com"):
|
|
130
174
|
return True
|
|
131
|
-
return page is not None and page.locator(
|
|
175
|
+
return page is not None and page.locator(CAREER_SITE).count() > 0
|
|
132
176
|
|
|
133
177
|
def posting(self, url: str) -> str:
|
|
134
178
|
parts = urlsplit(url)
|
|
@@ -145,10 +189,19 @@ class Teamtailor:
|
|
|
145
189
|
job = JOB.search(parts.path)
|
|
146
190
|
assert job, page.url # detected on a job URL
|
|
147
191
|
page.goto(f"{parts.scheme}://{parts.netloc}{parts.path[: job.end()]}/applications/new")
|
|
192
|
+
if urlsplit(page.url).hostname != parts.hostname:
|
|
193
|
+
raise Unsupported(f"form_shape: the job applies on another site ({page.url})")
|
|
148
194
|
form = page.locator(FORM)
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
195
|
+
try:
|
|
196
|
+
form.wait_for()
|
|
197
|
+
except browser.Timeout:
|
|
198
|
+
raise Unsupported("posting_gone: no application form on the page") from None
|
|
199
|
+
fields, self._qualifying, self._follow_ups = [], set(), {}
|
|
200
|
+
for raw in form.evaluate(_READ, QUESTION):
|
|
201
|
+
if raw.get("qualifying"):
|
|
202
|
+
self._qualifying.add(raw["id"])
|
|
203
|
+
if raw.get("conditional"):
|
|
204
|
+
self._follow_ups[raw["id"]] = raw["asked"]
|
|
152
205
|
field = _field(raw)
|
|
153
206
|
if field is None and raw["required"]:
|
|
154
207
|
raise Unsupported(f"form_shape: {raw['label']!r} is a {raw['kind']} question")
|
|
@@ -160,30 +213,22 @@ class Teamtailor:
|
|
|
160
213
|
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
161
214
|
form = page.locator(FORM)
|
|
162
215
|
_decline_cookies(page)
|
|
163
|
-
for field_id
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
_pick(page, host, value)
|
|
176
|
-
case FieldType.TEXT | FieldType.DATE | FieldType.PHONE, str():
|
|
177
|
-
box = host.locator("input:not([type=hidden]):not([type=search]), textarea")
|
|
178
|
-
box.first.fill(answer)
|
|
179
|
-
case kind, _:
|
|
180
|
-
raise Unsupported(
|
|
181
|
-
f"form_shape: cannot fill {field_id}, a {kind}, with {answer!r}"
|
|
182
|
-
)
|
|
216
|
+
for field_id in [i for i in answers if i in self._qualifying]:
|
|
217
|
+
self._fill(form, field_id, answers[field_id])
|
|
218
|
+
if form.locator(LOCKED).count():
|
|
219
|
+
raise Unsupported("not_qualified: the qualifying answers leave the form locked")
|
|
220
|
+
for field_id in [i for i in answers if i not in self._qualifying]:
|
|
221
|
+
if field_id in self._follow_ups and not self._shown(form, field_id):
|
|
222
|
+
continue # not asked: the read-back leaves it out
|
|
223
|
+
self._fill(form, field_id, answers[field_id])
|
|
224
|
+
for field_id, required in self._follow_ups.items():
|
|
225
|
+
if required and field_id not in answers and self._shown(form, field_id):
|
|
226
|
+
label = self._fields[field_id].label
|
|
227
|
+
raise Unsupported(f"missing_answer: an earlier answer asks {label!r}")
|
|
183
228
|
self._wait_for_uploads(form, answers)
|
|
184
229
|
|
|
185
230
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
186
|
-
values = page.locator(FORM).evaluate(_READ_BACK)
|
|
231
|
+
values = page.locator(FORM).evaluate(_READ_BACK, QUESTION)
|
|
187
232
|
return {k: v for k, v in values.items() if v not in (None, "", [])}
|
|
188
233
|
|
|
189
234
|
def submit(self, page: Page) -> str | None:
|
|
@@ -191,13 +236,53 @@ class Teamtailor:
|
|
|
191
236
|
page.wait_for_url(re.compile(r"/applications/\d+/thanks"), timeout=SUBMIT_TIMEOUT_MS)
|
|
192
237
|
return page.url
|
|
193
238
|
|
|
239
|
+
def _fill(self, form: Locator, field_id: str, answer: Answer) -> None:
|
|
240
|
+
page, field, host = form.page, self._fields[field_id], self._host(form, field_id)
|
|
241
|
+
match field.type, answer:
|
|
242
|
+
case FieldType.FILE, str():
|
|
243
|
+
host.locator("input[type=file]").set_input_files(answer)
|
|
244
|
+
case FieldType.CHOICE, str() if host.locator("input[type=checkbox]").count():
|
|
245
|
+
box = host.locator("input[type=checkbox]")
|
|
246
|
+
if box.is_checked() != (answer == "Yes"):
|
|
247
|
+
_click(page, page.locator(f"label[for='{box.get_attribute('id')}']"))
|
|
248
|
+
case FieldType.CHOICE, str() if host.locator("input[type=range]").count():
|
|
249
|
+
host.locator("input[type=range]").fill(answer)
|
|
250
|
+
case FieldType.TYPEAHEAD, str():
|
|
251
|
+
_suggest(page, field, host, answer)
|
|
252
|
+
case FieldType.CHOICE | FieldType.MULTI_CHOICE, _:
|
|
253
|
+
for value in [answer] if isinstance(answer, str) else answer:
|
|
254
|
+
_pick(page, host, value)
|
|
255
|
+
trigger = host.locator("button[aria-haspopup][aria-expanded=true]")
|
|
256
|
+
if trigger.count(): # a multiple choice menu stays open over the next fields
|
|
257
|
+
_click(page, trigger.first)
|
|
258
|
+
case FieldType.TEXT, str() if host.locator("input[type=number]").count():
|
|
259
|
+
if re.fullmatch(r"-?\d+(\.\d+)?", answer): # else left empty: a mismatch
|
|
260
|
+
host.locator("input[type=number]").first.fill(answer)
|
|
261
|
+
case FieldType.TEXT | FieldType.DATE | FieldType.PHONE, str():
|
|
262
|
+
box = host.locator("input:not([type=hidden]):not([type=search]), textarea")
|
|
263
|
+
box.first.fill(answer)
|
|
264
|
+
case kind, _:
|
|
265
|
+
raise Unsupported(f"form_shape: cannot fill {field_id}, a {kind}, with {answer!r}")
|
|
266
|
+
|
|
267
|
+
def _shown(self, form: Locator, field_id: str) -> bool:
|
|
268
|
+
"""Whether a follow-up question shows, once the answer before it has opened it."""
|
|
269
|
+
try:
|
|
270
|
+
self._host(form, field_id).wait_for(timeout=FOLLOW_UP_TIMEOUT_MS)
|
|
271
|
+
except browser.Timeout:
|
|
272
|
+
return False
|
|
273
|
+
return True
|
|
274
|
+
|
|
194
275
|
def _host(self, form: Locator, field_id: str) -> Locator:
|
|
195
276
|
"""The element holding a field's inputs: its question, else its input's container."""
|
|
196
277
|
if field_id.startswith("question:"):
|
|
197
278
|
qid = field_id.partition(":")[2]
|
|
198
|
-
return form.locator(
|
|
279
|
+
return form.locator(QUESTION).filter(
|
|
199
280
|
has=form.page.locator(f"input[name$='[question_id]'][value='{qid}']")
|
|
200
281
|
)
|
|
282
|
+
if field_id.endswith("[]"):
|
|
283
|
+
return form.locator("fieldset").filter(has=form.page.locator(f"[name='{field_id}']"))
|
|
284
|
+
if field_id == LOCATION:
|
|
285
|
+
return form.locator("[data-controller~='forms--inputs--location']")
|
|
201
286
|
if field_id.endswith("_remote_url]"):
|
|
202
287
|
dom_id = "candidate_" + field_id[len("candidate[") : -1]
|
|
203
288
|
return form.locator("[data-controller~='forms--inputs--upload']").filter(
|
|
@@ -213,7 +298,7 @@ class Teamtailor:
|
|
|
213
298
|
}
|
|
214
299
|
deadline = time.monotonic() + UPLOAD_TIMEOUT_S
|
|
215
300
|
while files and time.monotonic() < deadline:
|
|
216
|
-
held = form.evaluate(_READ_BACK)
|
|
301
|
+
held = form.evaluate(_READ_BACK, QUESTION)
|
|
217
302
|
if all(held.get(i) == name for i, name in files.items()):
|
|
218
303
|
return
|
|
219
304
|
form.page.wait_for_timeout(500)
|
|
@@ -255,6 +340,21 @@ def _click(page: Page, target: Locator) -> None:
|
|
|
255
340
|
browser.click(target)
|
|
256
341
|
|
|
257
342
|
|
|
343
|
+
def _suggest(page: Page, field: Field, host: Locator, answer: str) -> None:
|
|
344
|
+
"""Types the address key by key, which loads the suggestions, and picks the first that
|
|
345
|
+
matches it: the form keeps an address only once a suggestion is picked."""
|
|
346
|
+
box = host.locator("input[role=combobox]")
|
|
347
|
+
box.fill("")
|
|
348
|
+
box.press_sequentially(answer, delay=30)
|
|
349
|
+
options = host.locator("[role=option]")
|
|
350
|
+
options.first.wait_for()
|
|
351
|
+
for i in range(options.count()):
|
|
352
|
+
if matching.matches(field, answer, options.nth(i).get_attribute("data-description") or ""):
|
|
353
|
+
_click(page, options.nth(i))
|
|
354
|
+
return
|
|
355
|
+
raise Unsupported(f"form_shape: no suggestion for {answer!r} in {field.id}")
|
|
356
|
+
|
|
357
|
+
|
|
258
358
|
def _pick(page: Page, question: Locator, value: str) -> None:
|
|
259
359
|
"""Checks the option whose text is `value`, a radio or checkbox label or a dropdown item."""
|
|
260
360
|
label = question.locator("label:visible").filter(
|
|
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
|