atsfill 0.0.3.dev12__tar.gz → 0.0.3.dev14__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.dev12 → atsfill-0.0.3.dev14}/PKG-INFO +2 -2
- {atsfill-0.0.3.dev12 → atsfill-0.0.3.dev14}/README.md +1 -1
- {atsfill-0.0.3.dev12 → atsfill-0.0.3.dev14}/pyproject.toml +1 -1
- {atsfill-0.0.3.dev12 → atsfill-0.0.3.dev14}/pyproject.toml.orig +1 -1
- {atsfill-0.0.3.dev12 → atsfill-0.0.3.dev14}/src/atsfill/adapters/teamtailor.py +147 -47
- {atsfill-0.0.3.dev12 → atsfill-0.0.3.dev14}/src/atsfill/adapters/wttj.py +32 -15
- {atsfill-0.0.3.dev12 → atsfill-0.0.3.dev14}/src/atsfill/core/matching.py +1 -1
- {atsfill-0.0.3.dev12 → atsfill-0.0.3.dev14}/src/atsfill/core/results.py +1 -0
- {atsfill-0.0.3.dev12 → atsfill-0.0.3.dev14}/src/atsfill/registry.toml +2 -0
- {atsfill-0.0.3.dev12 → atsfill-0.0.3.dev14}/LICENSE +0 -0
- {atsfill-0.0.3.dev12 → atsfill-0.0.3.dev14}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.3.dev12 → atsfill-0.0.3.dev14}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.3.dev12 → atsfill-0.0.3.dev14}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.3.dev12 → atsfill-0.0.3.dev14}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.3.dev12 → atsfill-0.0.3.dev14}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.3.dev12 → atsfill-0.0.3.dev14}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.3.dev12 → atsfill-0.0.3.dev14}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.3.dev12 → atsfill-0.0.3.dev14}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.3.dev12 → atsfill-0.0.3.dev14}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.3.dev12 → atsfill-0.0.3.dev14}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.3.dev12 → atsfill-0.0.3.dev14}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.3.dev12 → atsfill-0.0.3.dev14}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.3.dev12 → atsfill-0.0.3.dev14}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.3.dev12 → atsfill-0.0.3.dev14}/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.dev14
|
|
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(
|
|
@@ -17,11 +17,14 @@ from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
|
17
17
|
|
|
18
18
|
HOST = "www.welcometothejungle.com"
|
|
19
19
|
JOB = re.compile(r"^/(?:[a-z]{2}/)?companies/([^/]+)/jobs/([^/?#]+)")
|
|
20
|
-
FORM = "form:has([
|
|
20
|
+
FORM = "form:has([data-testid=apply-form-submit])"
|
|
21
21
|
ME = "https://api.welcometothejungle.com/api/v2/users/me"
|
|
22
22
|
FORM_TIMEOUT_MS = 20_000
|
|
23
23
|
SIGNED_IN_TIMEOUT_S = 10
|
|
24
24
|
SUBMIT_TIMEOUT_MS = 90_000
|
|
25
|
+
SUGGEST_TRIES = 3
|
|
26
|
+
LOCATION_TIMEOUT_S = 3
|
|
27
|
+
SUGGEST_TIMEOUT_S = 5
|
|
25
28
|
YES_NO = ["Yes", "No"]
|
|
26
29
|
|
|
27
30
|
# The fillable fields: an input's name is its id. Email comes from the account, disabled.
|
|
@@ -33,7 +36,9 @@ _READ = """form => [...form.querySelectorAll("input[name], textarea[name]")]
|
|
|
33
36
|
label: (label ?? e.closest("fieldset")?.querySelector("legend"))?.textContent
|
|
34
37
|
.replace(/\\s+/g, " ").trim() ?? "",
|
|
35
38
|
required: e.required || label?.hasAttribute("required"),
|
|
36
|
-
accept: e.accept ? e.accept.split(",") : null
|
|
39
|
+
accept: e.accept ? e.accept.split(",") : null,
|
|
40
|
+
prefilled: e.type === "checkbox" ? e.checked
|
|
41
|
+
: e.type === "file" ? !!e.parentElement.querySelector("[data-id]") : !!e.value};
|
|
37
42
|
})"""
|
|
38
43
|
|
|
39
44
|
_READ_BACK = """(form, ids) => Object.fromEntries(ids.map(id => {
|
|
@@ -98,6 +103,10 @@ class WelcomeToTheJungle:
|
|
|
98
103
|
form.wait_for(timeout=FORM_TIMEOUT_MS)
|
|
99
104
|
except Timeout:
|
|
100
105
|
raise Unsupported("form_shape: the apply button opened no form") from None
|
|
106
|
+
location = form.locator("[name=location]")
|
|
107
|
+
deadline = time.monotonic() + LOCATION_TIMEOUT_S
|
|
108
|
+
while location.count() and not location.input_value() and time.monotonic() < deadline:
|
|
109
|
+
page.wait_for_timeout(200) # the profile's location comes last, a second later
|
|
101
110
|
fields = []
|
|
102
111
|
for raw in form.evaluate(_READ):
|
|
103
112
|
field = _field(raw)
|
|
@@ -114,11 +123,12 @@ class WelcomeToTheJungle:
|
|
|
114
123
|
target = form.locator(f'[name="{field_id}"]')
|
|
115
124
|
match self._fields[field_id].type, answer:
|
|
116
125
|
case FieldType.FILE, str():
|
|
117
|
-
|
|
126
|
+
box = target.locator("xpath=..")
|
|
127
|
+
remove = box.locator("button[title=Remove]")
|
|
118
128
|
if remove.count():
|
|
119
129
|
remove.click() # the profile's CV; never made the default
|
|
120
130
|
target.set_input_files(answer)
|
|
121
|
-
|
|
131
|
+
box.locator(f'[data-id="{answer.rsplit("/", 1)[-1]}"]').wait_for()
|
|
122
132
|
case FieldType.CHOICE, str():
|
|
123
133
|
if target.is_checked() != (answer == "Yes"):
|
|
124
134
|
browser.click(form.locator(f'label[for="{target.get_attribute("id")}"]'))
|
|
@@ -149,17 +159,21 @@ class WelcomeToTheJungle:
|
|
|
149
159
|
|
|
150
160
|
def _suggest(page: Page, field: Field, box: Locator, answer: str) -> None:
|
|
151
161
|
"""Retypes the answer key by key, which loads the suggestions, and picks the first that
|
|
152
|
-
matches it, never "current position" (the item with an icon, which geolocates).
|
|
153
|
-
|
|
154
|
-
box.press("Control+a") # `fill("")`, select-all and Delete, leaves the profile's value
|
|
155
|
-
box.press("Backspace")
|
|
156
|
-
box.press_sequentially(answer, delay=30)
|
|
162
|
+
matches it, never "current position" (the item with an icon, which geolocates). Typed too
|
|
163
|
+
early, the answer loads no suggestions at all: it is typed again."""
|
|
157
164
|
options = page.locator("[role=option]:visible").filter(has_not=page.locator("svg"))
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
165
|
+
for _ in range(SUGGEST_TRIES):
|
|
166
|
+
box.click()
|
|
167
|
+
box.press("Control+a") # `fill("")`, select-all and Delete, leaves the profile's value
|
|
168
|
+
box.press("Backspace")
|
|
169
|
+
box.press_sequentially(answer, delay=30)
|
|
170
|
+
deadline = time.monotonic() + SUGGEST_TIMEOUT_S
|
|
171
|
+
while time.monotonic() < deadline:
|
|
172
|
+
for option in options.all():
|
|
173
|
+
if matching.matches(field, answer, option.inner_text().strip()):
|
|
174
|
+
option.click()
|
|
175
|
+
return
|
|
176
|
+
page.wait_for_timeout(250)
|
|
163
177
|
raise Unsupported(f"form_shape: no suggestion for {answer!r} in {field.id}")
|
|
164
178
|
|
|
165
179
|
|
|
@@ -177,4 +191,7 @@ def _field(raw: dict[str, Any]) -> Field | None:
|
|
|
177
191
|
)
|
|
178
192
|
if kind is None:
|
|
179
193
|
return None
|
|
180
|
-
return Field(
|
|
194
|
+
return Field(
|
|
195
|
+
name, raw["label"], kind, bool(raw["required"]), options, None, raw["accept"],
|
|
196
|
+
raw["prefilled"],
|
|
197
|
+
) # fmt: skip
|
|
@@ -91,7 +91,7 @@ def problems(fields: list[Field], answers: dict[str, Answer]) -> list[str]:
|
|
|
91
91
|
out += [
|
|
92
92
|
f"no answer for required {f.id} ({f.label})"
|
|
93
93
|
for f in fields
|
|
94
|
-
if f.required and f.id not in answers
|
|
94
|
+
if f.required and not f.prefilled and f.id not in answers
|
|
95
95
|
]
|
|
96
96
|
out += [
|
|
97
97
|
f"{i}: {problem}"
|
|
@@ -27,6 +27,7 @@ kind = "ats"
|
|
|
27
27
|
account = "none"
|
|
28
28
|
hosting = ["hosted"]
|
|
29
29
|
adapter = "teamtailor"
|
|
30
|
+
dry_run_verified = { date = 2026-10-19, version = "0.0.2", by = "gate", commit = "91a0bba" }
|
|
30
31
|
|
|
31
32
|
[greenhouse]
|
|
32
33
|
kind = "ats"
|
|
@@ -40,3 +41,4 @@ kind = "board"
|
|
|
40
41
|
account = "to_send"
|
|
41
42
|
hosting = ["hosted"]
|
|
42
43
|
adapter = "wttj"
|
|
44
|
+
dry_run_verified = { date = 2026-10-19, version = "0.0.2", by = "gate", commit = "ad9f478" }
|
|
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
|