atsfill 0.0.4.dev63__tar.gz → 0.0.4.dev65__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.4.dev63 → atsfill-0.0.4.dev65}/PKG-INFO +1 -1
- {atsfill-0.0.4.dev63 → atsfill-0.0.4.dev65}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev63 → atsfill-0.0.4.dev65}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev63 → atsfill-0.0.4.dev65}/src/atsfill/adapters/phenom.py +19 -9
- {atsfill-0.0.4.dev63 → atsfill-0.0.4.dev65}/src/atsfill/adapters/teamtailor.py +51 -12
- {atsfill-0.0.4.dev63 → atsfill-0.0.4.dev65}/LICENSE +0 -0
- {atsfill-0.0.4.dev63 → atsfill-0.0.4.dev65}/README.md +0 -0
- {atsfill-0.0.4.dev63 → atsfill-0.0.4.dev65}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev63 → atsfill-0.0.4.dev65}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.4.dev63 → atsfill-0.0.4.dev65}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev63 → atsfill-0.0.4.dev65}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.4.dev63 → atsfill-0.0.4.dev65}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev63 → atsfill-0.0.4.dev65}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev63 → atsfill-0.0.4.dev65}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev63 → atsfill-0.0.4.dev65}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev63 → atsfill-0.0.4.dev65}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev63 → atsfill-0.0.4.dev65}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev63 → atsfill-0.0.4.dev65}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev63 → atsfill-0.0.4.dev65}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev63 → atsfill-0.0.4.dev65}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev63 → atsfill-0.0.4.dev65}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev63 → atsfill-0.0.4.dev65}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.4.dev63 → atsfill-0.0.4.dev65}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev63 → atsfill-0.0.4.dev65}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev63 → atsfill-0.0.4.dev65}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev63 → atsfill-0.0.4.dev65}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev63 → atsfill-0.0.4.dev65}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev63 → atsfill-0.0.4.dev65}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev63 → atsfill-0.0.4.dev65}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev63 → atsfill-0.0.4.dev65}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev63 → atsfill-0.0.4.dev65}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev63 → atsfill-0.0.4.dev65}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev63 → atsfill-0.0.4.dev65}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev63 → atsfill-0.0.4.dev65}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev63 → atsfill-0.0.4.dev65}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev63 → atsfill-0.0.4.dev65}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev63 → atsfill-0.0.4.dev65}/src/atsfill/py.typed +0 -0
- {atsfill-0.0.4.dev63 → atsfill-0.0.4.dev65}/src/atsfill/registry.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: atsfill
|
|
3
|
-
Version: 0.0.4.
|
|
3
|
+
Version: 0.0.4.dev65
|
|
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
|
|
@@ -311,6 +311,7 @@ class Phenom:
|
|
|
311
311
|
return None
|
|
312
312
|
|
|
313
313
|
def read_form(self, page: Page) -> list[Field]:
|
|
314
|
+
_check_open(page)
|
|
314
315
|
self._apply = _apply_url(page)
|
|
315
316
|
self._open(page)
|
|
316
317
|
try:
|
|
@@ -379,6 +380,7 @@ class Phenom:
|
|
|
379
380
|
page.evaluate("() => { localStorage.clear(); sessionStorage.clear(); }")
|
|
380
381
|
if fresh or not page.url.startswith(self._apply):
|
|
381
382
|
page.goto(self._apply, wait_until="domcontentloaded")
|
|
383
|
+
_check_open(page)
|
|
382
384
|
try:
|
|
383
385
|
page.locator(f"{FORM} {NEXT}, {FORM} button[type=submit]").first.wait_for(
|
|
384
386
|
state="attached", timeout=FORM_TIMEOUT_MS
|
|
@@ -421,17 +423,25 @@ def get(obj: Any, path: str) -> Any:
|
|
|
421
423
|
|
|
422
424
|
def _phapp_of(html: str) -> dict[str, Any]:
|
|
423
425
|
"""The apply page's `phApp`, from the object literals its scripts assign."""
|
|
426
|
+
ph = _literal(html, "var phApp = phApp || ")
|
|
427
|
+
ph["ddo"] = _literal(html, "phApp.ddo = ")
|
|
428
|
+
ph["sessionParams"] = _literal(html, "phApp.sessionParams = ")
|
|
429
|
+
return ph
|
|
424
430
|
|
|
425
|
-
def literal(marker: str) -> Any:
|
|
426
|
-
at = html.find(marker)
|
|
427
|
-
if at < 0:
|
|
428
|
-
raise ValueError(f"no {marker.strip()} on the page")
|
|
429
|
-
return json.JSONDecoder().raw_decode(html, at + len(marker))[0]
|
|
430
431
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
432
|
+
def _literal(html: str, marker: str) -> Any:
|
|
433
|
+
at = html.find(marker)
|
|
434
|
+
if at < 0:
|
|
435
|
+
raise ValueError(f"no {marker.strip()} on the page")
|
|
436
|
+
return json.JSONDecoder().raw_decode(html, at + len(marker))[0]
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _check_open(page: Page) -> None:
|
|
440
|
+
"""Raises `posting_gone` on Phenom's page for a closed posting, which a closed job's URL
|
|
441
|
+
and its wizard's both serve."""
|
|
442
|
+
with suppress(ValueError):
|
|
443
|
+
if _literal(page.content(), "var phApp = phApp || ").get("pageName") == "404":
|
|
444
|
+
raise Unsupported("posting_gone: the careers site answers its 404 page")
|
|
435
445
|
|
|
436
446
|
|
|
437
447
|
def _read(
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"""Teamtailor (`<employer>.teamtailor.com` and career sites on the employer's domain), as
|
|
2
2
|
docs/adapters/teamtailor.md observes.
|
|
3
3
|
|
|
4
|
-
The form posts nothing before submit except the
|
|
5
|
-
soon as
|
|
4
|
+
The form posts nothing before submit except the files, which go to Teamtailor's storage as
|
|
5
|
+
soon as they are picked. The read-back is the form's `FormData`, and for a video question the
|
|
6
|
+
video Teamtailor holds under the id the form keeps.
|
|
6
7
|
"""
|
|
7
8
|
|
|
8
9
|
import re
|
|
@@ -14,7 +15,7 @@ from urllib.parse import urlsplit
|
|
|
14
15
|
|
|
15
16
|
from atsfill.core import browser, matching
|
|
16
17
|
from atsfill.core.adapter import Gap, NotTaken, Unsupported, known_gap
|
|
17
|
-
from atsfill.core.browser import Locator, Page
|
|
18
|
+
from atsfill.core.browser import Locator, Page, Response
|
|
18
19
|
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
19
20
|
|
|
20
21
|
FORM = "#job-application-form"
|
|
@@ -23,11 +24,13 @@ FORM = "#job-application-form"
|
|
|
23
24
|
QUESTION = ".question, [data-careersite--form-target=qualifyingAnswers]"
|
|
24
25
|
LOCKED = "[data-careersite--form-target=formContentWrapper].cursor-not-allowed"
|
|
25
26
|
LOCATION = "candidate[location][query]"
|
|
27
|
+
VIDEO = "[data-controller~='questions--tt-video-answer']"
|
|
26
28
|
CAREER_SITE = "a[href*='//www.teamtailor.com'], script[src*='teamtailor-cdn.com/assets/careersite']"
|
|
27
29
|
JOB = re.compile(r"/jobs/(\d+)[^/]*")
|
|
28
30
|
SUBMIT_TIMEOUT_MS = 90_000
|
|
29
31
|
FOLLOW_UP_TIMEOUT_MS = 1_000
|
|
30
32
|
UPLOAD_TIMEOUT_S = 60
|
|
33
|
+
UPLOAD_TIMEOUT_MS = UPLOAD_TIMEOUT_S * 1000
|
|
31
34
|
UPLOAD_POLL_MS = 500
|
|
32
35
|
KEY_DELAY_MS = 30
|
|
33
36
|
QUESTION_TYPES = {
|
|
@@ -109,13 +112,16 @@ _READ = """(form, QUESTION) => {
|
|
|
109
112
|
label: label(q.querySelector("legend, label")) + (when || ""),
|
|
110
113
|
required: qualifying || (!parent && q.dataset.questionMandatory === "true"),
|
|
111
114
|
asked: q.dataset.questionMandatory === "true",
|
|
115
|
+
accept: kind === "video"
|
|
116
|
+
? q.querySelector("input[type=file]")?.accept.split(",").filter(Boolean) : null,
|
|
112
117
|
options: picks.length ? picks : menu.length ? menu : null});
|
|
113
118
|
}
|
|
114
119
|
return fields;
|
|
115
120
|
}"""
|
|
116
121
|
|
|
117
122
|
# What the form will send: each field's value, a choice as its option's text, a file as the
|
|
118
|
-
# uploaded file's name, an address once a suggestion is picked
|
|
123
|
+
# uploaded file's name, an address once a suggestion is picked, a video question as the id of
|
|
124
|
+
# the video it holds.
|
|
119
125
|
_READ_BACK = """(form, QUESTION) => {
|
|
120
126
|
const data = new FormData(form);
|
|
121
127
|
const text = e => {
|
|
@@ -149,7 +155,9 @@ _READ_BACK = """(form, QUESTION) => {
|
|
|
149
155
|
const id = `question:${q.querySelector("input[name$='[question_id]']")?.value}`;
|
|
150
156
|
const checked = [...q.querySelectorAll("input:checked")];
|
|
151
157
|
const named = q.querySelector("[name^='candidate[answers_attributes]']:not([type=hidden])");
|
|
152
|
-
|
|
158
|
+
const video = q.querySelector("[name$='[picked_video_attributes][video_id]']");
|
|
159
|
+
if (video) out[id] = data.get(video.name);
|
|
160
|
+
else if (q.querySelector("input[type=checkbox]")) out[id] = checked.map(text);
|
|
153
161
|
else if (q.querySelector("input[type=radio]")) out[id] = checked[0] ? text(checked[0]) : null;
|
|
154
162
|
else if (named) out[id] = named.value;
|
|
155
163
|
}
|
|
@@ -159,7 +167,7 @@ _READ_BACK = """(form, QUESTION) => {
|
|
|
159
167
|
|
|
160
168
|
class Teamtailor:
|
|
161
169
|
name = "teamtailor"
|
|
162
|
-
known_gaps = {"
|
|
170
|
+
known_gaps = {"file_question": Gap.RENDERING}
|
|
163
171
|
side_effects = [
|
|
164
172
|
SideEffect("upload", "hiring_system", "a picked file is stored by Teamtailor at once"),
|
|
165
173
|
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
@@ -170,6 +178,7 @@ class Teamtailor:
|
|
|
170
178
|
self._qualifying: set[str] = set()
|
|
171
179
|
self._follow_ups: dict[str, bool] = {} # shown by an earlier answer: required once shown
|
|
172
180
|
self._uploads: dict[str, str] = {} # file field id: the answer's file name
|
|
181
|
+
self._videos: dict[str, tuple[str, str]] = {} # video field id: its video id, its URL
|
|
173
182
|
|
|
174
183
|
def detect(self, url: str, page: Page | None) -> bool:
|
|
175
184
|
"""A `*.teamtailor.com` job, or a job page with Teamtailor's "powered by" link or its
|
|
@@ -211,6 +220,7 @@ class Teamtailor:
|
|
|
211
220
|
|
|
212
221
|
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
213
222
|
form = page.locator(FORM)
|
|
223
|
+
self._videos = {}
|
|
214
224
|
_decline_cookies(page)
|
|
215
225
|
for field_id in [i for i in answers if i in self._qualifying]:
|
|
216
226
|
self._fill(form, field_id, answers[field_id])
|
|
@@ -233,6 +243,9 @@ class Teamtailor:
|
|
|
233
243
|
|
|
234
244
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
235
245
|
values = page.locator(FORM).evaluate(_READ_BACK, QUESTION)
|
|
246
|
+
for field_id, (video_id, url) in self._videos.items():
|
|
247
|
+
if values.get(field_id) == video_id: # the name Teamtailor holds the video under
|
|
248
|
+
values[field_id] = page.evaluate(_VIDEO, url)
|
|
236
249
|
for field_id, name in self._uploads.items():
|
|
237
250
|
if values.get(field_id) == _stored_name(name):
|
|
238
251
|
values[field_id] = name
|
|
@@ -246,6 +259,8 @@ class Teamtailor:
|
|
|
246
259
|
def _fill(self, form: Locator, field_id: str, answer: Answer) -> None:
|
|
247
260
|
page, field, host = form.page, self._fields[field_id], self._host(form, field_id)
|
|
248
261
|
match field.type, answer:
|
|
262
|
+
case FieldType.FILE, str() if host.locator(VIDEO).count():
|
|
263
|
+
self._videos[field_id] = _upload_video(page, host, answer)
|
|
249
264
|
case FieldType.FILE, str():
|
|
250
265
|
host.locator("input[type=file]").set_input_files(answer)
|
|
251
266
|
case FieldType.CHOICE, str() if host.locator("input[type=checkbox]").count():
|
|
@@ -276,11 +291,8 @@ class Teamtailor:
|
|
|
276
291
|
def _field(self, raw: dict[str, Any]) -> Field:
|
|
277
292
|
field_id, label, required = raw["id"], raw["label"], raw["required"]
|
|
278
293
|
match raw["kind"]:
|
|
279
|
-
case "video" if raw.get("question"):
|
|
280
|
-
|
|
281
|
-
self, "video_upload", f"{field_id} is a video, recorded or uploaded"
|
|
282
|
-
)
|
|
283
|
-
return Field(field_id, label, FieldType.FILE, required, unfillable=gap)
|
|
294
|
+
case "video" if raw.get("question"): # a clip is uploaded, never recorded
|
|
295
|
+
return Field(field_id, label, FieldType.FILE, required, accept=raw.get("accept"))
|
|
284
296
|
case "file" if raw.get("question"):
|
|
285
297
|
gap = known_gap(self, "file_question", f"{field_id} is a question's file drop zone")
|
|
286
298
|
return Field(field_id, label, FieldType.FILE, required, unfillable=gap)
|
|
@@ -323,7 +335,11 @@ class Teamtailor:
|
|
|
323
335
|
deadline = time.monotonic() + UPLOAD_TIMEOUT_S
|
|
324
336
|
while self._uploads and time.monotonic() < deadline:
|
|
325
337
|
held = form.evaluate(_READ_BACK, QUESTION)
|
|
326
|
-
|
|
338
|
+
stored = {
|
|
339
|
+
i: self._videos[i][0] if i in self._videos else _stored_name(n)
|
|
340
|
+
for i, n in self._uploads.items()
|
|
341
|
+
}
|
|
342
|
+
if all(held.get(i) == s for i, s in stored.items()):
|
|
327
343
|
return
|
|
328
344
|
form.page.wait_for_timeout(UPLOAD_POLL_MS)
|
|
329
345
|
|
|
@@ -335,6 +351,29 @@ def _stored_name(name: str) -> str:
|
|
|
335
351
|
return re.sub(r"[^a-z0-9.\-]+", "_", ascii_name.lower())
|
|
336
352
|
|
|
337
353
|
|
|
354
|
+
def _upload_video(page: Page, host: Locator, clip: str) -> tuple[str, str]:
|
|
355
|
+
"""Uploads a clip through a video question's "Upload video" input, never its recorder.
|
|
356
|
+
Teamtailor stores it as a video and the form keeps the video's id; returns that id and the
|
|
357
|
+
URL the video is read back from."""
|
|
358
|
+
|
|
359
|
+
def created(response: Response) -> bool:
|
|
360
|
+
path = urlsplit(response.url).path
|
|
361
|
+
return response.request.method == "POST" and path.endswith("/videos")
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
with page.expect_response(created, timeout=UPLOAD_TIMEOUT_MS) as response:
|
|
365
|
+
host.locator("input[type=file]").set_input_files(clip)
|
|
366
|
+
except browser.Timeout:
|
|
367
|
+
raise NotTaken(f"the video question took no video from {PurePath(clip).name!r}") from None
|
|
368
|
+
video = response.value.json()
|
|
369
|
+
return str(video["id"]), f"{response.value.url}/{video['sgid']}"
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# The file name Teamtailor holds a video under.
|
|
373
|
+
_VIDEO = """url => fetch(url, {headers: {Accept: "application/json"}})
|
|
374
|
+
.then(r => r.ok ? r.json() : {}).then(v => v.file_file_name ?? null)"""
|
|
375
|
+
|
|
376
|
+
|
|
338
377
|
def _decline_cookies(page: Page) -> None:
|
|
339
378
|
"""Closes the cookie banner, a modal dialog that leaves the form inert while it is open."""
|
|
340
379
|
decline = page.locator("[data-action*='cookies--alert#disableAll']").first
|
|
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
|
|
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
|