atsfill 0.0.5.dev158__tar.gz → 0.0.5.dev161__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.5.dev158 → atsfill-0.0.5.dev161}/PKG-INFO +2 -1
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/README.md +1 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/pyproject.toml +1 -1
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/pyproject.toml.orig +1 -1
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/adapters/__init__.py +2 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/adapters/join.py +182 -6
- atsfill-0.0.5.dev161/src/atsfill/adapters/welcomekit.py +366 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/registry.toml +7 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/LICENSE +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/adapters/gupy.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/adapters/hrmdirect.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/adapters/isolvedhire.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/adapters/jobaffinity.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/adapters/jobstreet.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/adapters/recruiterflow.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/adapters/successfactors.js +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/adapters/successfactors.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/adapters/taleo.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/adapters/ukg.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/adapters/workatastartup.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/core/otp.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/core/snapshot.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/core/telemetry.py +0 -0
- {atsfill-0.0.5.dev158 → atsfill-0.0.5.dev161}/src/atsfill/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: atsfill
|
|
3
|
-
Version: 0.0.5.
|
|
3
|
+
Version: 0.0.5.dev161
|
|
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
|
|
@@ -83,6 +83,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
83
83
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
84
84
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
85
85
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
86
|
+
[](https://shbernal.github.io/atsfill/run-history/)
|
|
86
87
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
87
88
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
88
89
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
@@ -54,6 +54,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
54
54
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
55
55
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
56
56
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
57
|
+
[](https://shbernal.github.io/atsfill/run-history/)
|
|
57
58
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
58
59
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
59
60
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
@@ -27,6 +27,7 @@ from atsfill.adapters.smartrecruiters import SmartRecruiters
|
|
|
27
27
|
from atsfill.adapters.successfactors import SuccessFactors
|
|
28
28
|
from atsfill.adapters.teamtailor import Teamtailor
|
|
29
29
|
from atsfill.adapters.ukg import UKG
|
|
30
|
+
from atsfill.adapters.welcomekit import WelcomeKit
|
|
30
31
|
from atsfill.adapters.workable import Workable
|
|
31
32
|
from atsfill.adapters.workatastartup import WorkAtAStartup
|
|
32
33
|
from atsfill.adapters.workday import Workday
|
|
@@ -45,6 +46,7 @@ def adapters() -> list[Adapter]:
|
|
|
45
46
|
WorkAtAStartup(),
|
|
46
47
|
Join(),
|
|
47
48
|
JobAffinity(),
|
|
49
|
+
WelcomeKit(),
|
|
48
50
|
SmartRecruiters(),
|
|
49
51
|
Oracle(),
|
|
50
52
|
Manatal(),
|
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
Applying is a wizard of one step per page, ending on a review page whose Confirm & apply is the
|
|
4
4
|
send. Signed in, the wizard takes the name from the account and asks the CV, a cover letter and
|
|
5
5
|
the job's screening questions, which the page's own GraphQL query lists. The fill walks the
|
|
6
|
-
steps to the review page, and the read-back is what the review page lists.
|
|
6
|
+
steps to the review page, and the read-back is what the review page lists. What the wizard takes
|
|
7
|
+
from the account (name, phone, country, city, links) is the account profile's, a form of one
|
|
8
|
+
profile step that `profile` saves (ADR 0017).
|
|
7
9
|
"""
|
|
8
10
|
|
|
9
11
|
import json
|
|
10
12
|
import re
|
|
11
13
|
import time
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from dataclasses import replace
|
|
12
16
|
from typing import Any
|
|
13
17
|
from urllib.parse import urlsplit
|
|
14
18
|
|
|
@@ -17,10 +21,10 @@ from atsfill.core.adapter import (
|
|
|
17
21
|
Blocked,
|
|
18
22
|
Gap,
|
|
19
23
|
NotTaken,
|
|
24
|
+
ProfileIncomplete,
|
|
20
25
|
Refused,
|
|
21
26
|
SignUpDetails,
|
|
22
27
|
Unsupported,
|
|
23
|
-
known_gap,
|
|
24
28
|
)
|
|
25
29
|
from atsfill.core.browser import Locator, Page, Request, Response, Timeout
|
|
26
30
|
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
@@ -31,7 +35,13 @@ ME = f"https://{HOST}/api/me"
|
|
|
31
35
|
GRAPHQL = f"https://{HOST}/candidate-api/graphql"
|
|
32
36
|
LOGIN = f"https://{HOST}/auth/login/candidate?redirectUrl=%2Ftalent%2Fhome"
|
|
33
37
|
UNVERIFIED = "/auth/candidates/unverified-email"
|
|
38
|
+
PROFILE = f"https://{HOST}/talent/me?edit=profile"
|
|
39
|
+
GENERAL = "general" # the account profile's one profile step
|
|
34
40
|
STEP_TIMEOUT_MS = 20_000
|
|
41
|
+
PROFILE_TIMEOUT_MS = 20_000
|
|
42
|
+
SAVE_TIMEOUT_MS = 20_000
|
|
43
|
+
MENU_TIMEOUT_MS = 5_000
|
|
44
|
+
SUGGEST_TIMEOUT_MS = 10_000 # the city's suggestions come from a request per search
|
|
35
45
|
UPLOAD_TIMEOUT_MS = 30_000
|
|
36
46
|
ACCOUNT_TIMEOUT_MS = 20_000
|
|
37
47
|
SUBMIT_TIMEOUT_MS = 60_000
|
|
@@ -57,8 +67,27 @@ CLASS_STEPS = {
|
|
|
57
67
|
"availability": "AVAILABILITY",
|
|
58
68
|
}
|
|
59
69
|
PROFILE_STEPS = ("personalInformation", "professionalLinks") # the account profile's
|
|
70
|
+
# What a wizard's profile step can ask that the account profile lacks, by input id.
|
|
71
|
+
LACKS = {
|
|
72
|
+
"candidate.firstName": "a first name",
|
|
73
|
+
"candidate.lastName": "a last name",
|
|
74
|
+
"candidate.phoneNumber": "a phone",
|
|
75
|
+
}
|
|
60
76
|
PDF = [".pdf", "application/pdf"]
|
|
61
77
|
|
|
78
|
+
# The profile form's controls, by its field ids.
|
|
79
|
+
TEXTS = {
|
|
80
|
+
"first_name": "candidate.firstName",
|
|
81
|
+
"last_name": "candidate.lastName",
|
|
82
|
+
"linkedin": "professionalLinks.LINKEDIN",
|
|
83
|
+
}
|
|
84
|
+
COUNTRY = '[id="candidate.countryId"]'
|
|
85
|
+
CITY = "[data-testid=CityName]"
|
|
86
|
+
PHONE = '[id="candidate.phoneNumber"]'
|
|
87
|
+
DIAL = "[data-phone-input] button" # the phone's country code, a popover of countries
|
|
88
|
+
POPOVER = "[data-scope=popover][data-part=content]"
|
|
89
|
+
DIAL_CODE = re.compile(r"\(\+(\d+)\)\s*$") # an option of the popover: `France (+33)`
|
|
90
|
+
|
|
62
91
|
# The page's own GraphQL request, from the page.
|
|
63
92
|
_FETCH = """async ([url, body]) => (await fetch(url, {method: "POST", body: JSON.stringify(body),
|
|
64
93
|
headers: {"content-type": "application/json"}})).json()"""
|
|
@@ -70,14 +99,26 @@ _REVIEW = """() => [...document.querySelectorAll("a > div")].map(div => {
|
|
|
70
99
|
? [label.textContent.trim(), value.textContent.trim()] : null;
|
|
71
100
|
}).filter(Boolean)"""
|
|
72
101
|
|
|
102
|
+
# A select's value: the paragraph it shows, not an option of its open menu.
|
|
103
|
+
_SHOWN = """root => {
|
|
104
|
+
const p = [...root.querySelectorAll("p")].find(p => !p.closest("[role=option]"));
|
|
105
|
+
return p ? p.textContent.trim() : "";
|
|
106
|
+
}"""
|
|
107
|
+
|
|
108
|
+
# The ids of the inputs that are empty, of those given.
|
|
109
|
+
_EMPTY = (
|
|
110
|
+
"""ids => ids.filter(id => { const e = document.getElementById(id); return e && !e.value; })"""
|
|
111
|
+
)
|
|
112
|
+
|
|
73
113
|
|
|
74
114
|
class Join:
|
|
75
115
|
name = "join"
|
|
76
116
|
per_employer = False
|
|
77
117
|
asks = {"name"} # the account's first and last name
|
|
78
|
-
known_gaps = {
|
|
118
|
+
known_gaps: dict[str, Gap] = {}
|
|
79
119
|
side_effects = [
|
|
80
120
|
SideEffect("sign_up", "hiring_system", "login --create creates the candidate account"),
|
|
121
|
+
SideEffect("profile", "hiring_system", "profile --answers saves the account profile"),
|
|
81
122
|
SideEffect("upload", "hiring_system", "a file is stored as soon as it is chosen"),
|
|
82
123
|
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
83
124
|
]
|
|
@@ -196,9 +237,11 @@ class Join:
|
|
|
196
237
|
elif step in PROFILE_STEPS:
|
|
197
238
|
try: # shown when the job requires what the account profile lacks
|
|
198
239
|
_next(page, step)
|
|
199
|
-
except NotTaken, RuntimeError:
|
|
200
|
-
|
|
201
|
-
raise
|
|
240
|
+
except NotTaken, RuntimeError: # Continue disabled, or refused on click
|
|
241
|
+
lacks = [LACKS[i] for i in page.evaluate(_EMPTY, list(LACKS))]
|
|
242
|
+
raise ProfileIncomplete(
|
|
243
|
+
f"the job asks for {' and '.join(lacks) or f'what {step} asks'}"
|
|
244
|
+
) from None
|
|
202
245
|
continue
|
|
203
246
|
else:
|
|
204
247
|
raise Unsupported(f"unhandled_rendering: a wizard step {step!r}")
|
|
@@ -239,6 +282,83 @@ class Join:
|
|
|
239
282
|
return f"POST {parts.path}?{parts.query} {response.status}"
|
|
240
283
|
raise RuntimeError(f"POST {response.url} answered {response.status}: {body.get('errors')}")
|
|
241
284
|
|
|
285
|
+
def profile_url(self, scope: str | None) -> str:
|
|
286
|
+
return PROFILE
|
|
287
|
+
|
|
288
|
+
def profile_steps(self, page: Page) -> list[str]:
|
|
289
|
+
return [GENERAL]
|
|
290
|
+
|
|
291
|
+
def open_step(self, page: Page, step: str) -> bool:
|
|
292
|
+
if step != GENERAL:
|
|
293
|
+
raise Unsupported(f"unhandled_rendering: a profile step {step!r}")
|
|
294
|
+
if page.url != PROFILE:
|
|
295
|
+
page.goto(PROFILE)
|
|
296
|
+
page.locator(f'[id="{TEXTS["first_name"]}"]').wait_for(timeout=PROFILE_TIMEOUT_MS)
|
|
297
|
+
return True
|
|
298
|
+
|
|
299
|
+
def profile_read(self, page: Page) -> list[Field]:
|
|
300
|
+
"""The general information form: the name, the phone, the country and city of residence
|
|
301
|
+
and the LinkedIn URL. The photo, the bio, the work authorizations and the other links are
|
|
302
|
+
left as they are, as are the profile's other sections (experience, education, …)."""
|
|
303
|
+
saved = self.profile_read_back(page)
|
|
304
|
+
limit = {
|
|
305
|
+
k: int(page.locator(f'[id="{i}"]').get_attribute("maxlength") or 0) or None
|
|
306
|
+
for k, i in TEXTS.items()
|
|
307
|
+
}
|
|
308
|
+
fields = [
|
|
309
|
+
Field("first_name", "First name", FieldType.TEXT, True, max_length=limit["first_name"]),
|
|
310
|
+
Field("last_name", "Last name", FieldType.TEXT, True, max_length=limit["last_name"]),
|
|
311
|
+
Field("phone", "Phone number, with its country code", FieldType.PHONE, False),
|
|
312
|
+
Field("country", "Country of residence", FieldType.CHOICE, True, _menu(page, COUNTRY)),
|
|
313
|
+
Field("city", "City, in the country of residence", FieldType.TYPEAHEAD, False),
|
|
314
|
+
Field("linkedin", "LinkedIn", FieldType.URL, False),
|
|
315
|
+
] # fmt: skip
|
|
316
|
+
return [replace(f, prefilled=saved.get(f.id)) for f in fields]
|
|
317
|
+
|
|
318
|
+
def profile_fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
319
|
+
"""In the form's order: the city is searched in the country of residence."""
|
|
320
|
+
for field_id, input_id in TEXTS.items():
|
|
321
|
+
if isinstance(answer := answers.get(field_id), str):
|
|
322
|
+
page.locator(f'[id="{input_id}"]').fill(answer)
|
|
323
|
+
if isinstance(answer := answers.get("phone"), str):
|
|
324
|
+
_phone(page, answer)
|
|
325
|
+
if isinstance(answer := answers.get("country"), str):
|
|
326
|
+
_pick(page, COUNTRY, answer, lambda text: text == answer, MENU_TIMEOUT_MS)
|
|
327
|
+
if isinstance(answer := answers.get("city"), str):
|
|
328
|
+
_pick(page, CITY, answer, lambda text: True, SUGGEST_TIMEOUT_MS)
|
|
329
|
+
|
|
330
|
+
def profile_read_back(self, page: Page) -> dict[str, Answer]:
|
|
331
|
+
values = {k: page.locator(f'[id="{i}"]').input_value() for k, i in TEXTS.items()}
|
|
332
|
+
phone = page.locator(PHONE)
|
|
333
|
+
if phone.input_value(): # `data-value` holds the country code and the number
|
|
334
|
+
values["phone"] = phone.get_attribute("data-value") or ""
|
|
335
|
+
values["country"] = _shown(page, COUNTRY)
|
|
336
|
+
values["city"] = _shown(page, CITY)
|
|
337
|
+
return {k: v for k, v in values.items() if v}
|
|
338
|
+
|
|
339
|
+
def save(self, page: Page) -> None:
|
|
340
|
+
"""Save changes posts `UpdateTalentPersonalInformation`; the button stays disabled while
|
|
341
|
+
a required field is empty or nothing changed."""
|
|
342
|
+
button = page.locator("form button[type=submit]").last
|
|
343
|
+
try: # it is enabled a moment after the last change
|
|
344
|
+
page.wait_for_function(
|
|
345
|
+
"e => !e.disabled", arg=button.element_handle(), timeout=BUTTON_TIMEOUT_MS
|
|
346
|
+
)
|
|
347
|
+
except Timeout:
|
|
348
|
+
raise Refused(
|
|
349
|
+
f"step_refused: {GENERAL}: the form keeps its Save button disabled"
|
|
350
|
+
) from None
|
|
351
|
+
with page.expect_response(
|
|
352
|
+
lambda r: "operationName=UpdateTalentPersonalInformation" in r.url,
|
|
353
|
+
timeout=SAVE_TIMEOUT_MS,
|
|
354
|
+
) as sent:
|
|
355
|
+
button.click()
|
|
356
|
+
response = sent.value
|
|
357
|
+
if not response.ok:
|
|
358
|
+
raise RuntimeError(f"UpdateTalentPersonalInformation answered {response.status}")
|
|
359
|
+
if errors := response.json().get("errors"):
|
|
360
|
+
raise Refused(f"step_refused: {GENERAL}: {errors[0].get('message')}")
|
|
361
|
+
|
|
242
362
|
def _file(self, page: Page, field_id: str, answer: Answer | None) -> None:
|
|
243
363
|
if not isinstance(answer, str):
|
|
244
364
|
return
|
|
@@ -348,6 +468,62 @@ def _date(page: Page, answer: str) -> None:
|
|
|
348
468
|
(trigger if trigger.count() else day.first).click()
|
|
349
469
|
|
|
350
470
|
|
|
471
|
+
def _select(page: Page, container: str) -> tuple[Locator, Locator]:
|
|
472
|
+
"""A select of the profile form: its combobox, and the options its menu shows, whose ids
|
|
473
|
+
carry the combobox's."""
|
|
474
|
+
box = page.locator(container).locator("input[role=combobox]")
|
|
475
|
+
suffix = (box.get_attribute("id") or "").removeprefix("select-input-")
|
|
476
|
+
return box, page.locator(f'[id^="react-select-{suffix}-option-"]')
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _menu(page: Page, container: str) -> list[str]:
|
|
480
|
+
box, options = _select(page, container)
|
|
481
|
+
box.click()
|
|
482
|
+
options.first.wait_for(timeout=MENU_TIMEOUT_MS)
|
|
483
|
+
texts = [t.strip() for t in options.all_inner_texts()]
|
|
484
|
+
box.press("Escape")
|
|
485
|
+
return texts
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _pick(page: Page, container: str, answer: str, take: Callable[[str], bool], ms: int) -> None:
|
|
489
|
+
"""Searches the select for the answer and picks the first option `take` accepts."""
|
|
490
|
+
box, options = _select(page, container)
|
|
491
|
+
box.click()
|
|
492
|
+
box.fill(answer)
|
|
493
|
+
try:
|
|
494
|
+
options.first.wait_for(timeout=ms)
|
|
495
|
+
except Timeout:
|
|
496
|
+
raise NotTaken(f"no option for {answer!r}") from None
|
|
497
|
+
texts = [t.strip() for t in options.all_inner_texts()]
|
|
498
|
+
picked = next((i for i, text in enumerate(texts) if take(text)), None)
|
|
499
|
+
if picked is None:
|
|
500
|
+
raise NotTaken(f"no option {answer!r} in {texts}")
|
|
501
|
+
options.nth(picked).click()
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _shown(page: Page, container: str) -> str:
|
|
505
|
+
"""A select's value as it shows, empty under its placeholder."""
|
|
506
|
+
return page.locator(container).first.evaluate(_SHOWN)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _phone(page: Page, answer: str) -> None:
|
|
510
|
+
"""Picks the country whose code starts the answer, the longest of them, then types the rest:
|
|
511
|
+
the number's input takes digits only."""
|
|
512
|
+
digits = re.sub(r"\D", "", answer)
|
|
513
|
+
if not answer.strip().startswith("+"):
|
|
514
|
+
raise NotTaken(f"the phone {answer!r} has no country code, `+<code> <number>`")
|
|
515
|
+
page.locator(DIAL).click()
|
|
516
|
+
options = page.locator(POPOVER).locator("[role=option]")
|
|
517
|
+
options.first.wait_for(timeout=MENU_TIMEOUT_MS)
|
|
518
|
+
codes = [m[1] if (m := DIAL_CODE.search(t)) else "" for t in options.all_inner_texts()]
|
|
519
|
+
starting = [i for i, code in enumerate(codes) if code and digits.startswith(code)]
|
|
520
|
+
if not starting:
|
|
521
|
+
raise NotTaken(f"no country code starts the phone {answer!r}")
|
|
522
|
+
picked = max(starting, key=lambda i: len(codes[i]))
|
|
523
|
+
options.nth(picked).click()
|
|
524
|
+
page.locator(PHONE).fill(digits[len(codes[picked]) :])
|
|
525
|
+
|
|
526
|
+
|
|
351
527
|
def _email_step(page: Page, email: str) -> bool:
|
|
352
528
|
"""The account page's first step: the email. Whether an account has it, from what the page
|
|
353
529
|
asks next: a password alone for one, a name and a password for none."""
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""WelcomeKit, Welcome to the Jungle's ATS, as docs/adapters/welcomekit.md observes.
|
|
2
|
+
|
|
3
|
+
Two forms, by host. The older one (`welcomekit.co/candidates/new?o=<org>&j=<job>`) is a Rails
|
|
4
|
+
form rendered on the server: `request_read` fetches it over HTTP, `browser_read` takes the page's
|
|
5
|
+
HTML, one parser lists the fields. The newer one (`ats.welcometothejungle.com/board/<org>/job/
|
|
6
|
+
<id>/apply`) is a React app over a JSON API: fixed personal fields, then the job's questions as
|
|
7
|
+
the API declares them; both reads list the fields from that JSON.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.request
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from urllib.parse import parse_qs, urlsplit
|
|
16
|
+
|
|
17
|
+
from atsfill.core import html
|
|
18
|
+
from atsfill.core.adapter import Blocked, Gap, Unsupported
|
|
19
|
+
from atsfill.core.browser import HTTP_TIMEOUT_S, Page, Timeout
|
|
20
|
+
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
21
|
+
|
|
22
|
+
OLD_ORIGIN = "https://www.welcomekit.co"
|
|
23
|
+
OLD_HOST = re.compile(r"^(?:www\.)?welcomekit\.co$")
|
|
24
|
+
OLD_PATH = "/candidates/new"
|
|
25
|
+
OLD = "form#new_candidate"
|
|
26
|
+
NEW_ORIGIN = "https://ats.welcometothejungle.com"
|
|
27
|
+
NEW_HOST = "ats.welcometothejungle.com"
|
|
28
|
+
NEW_PATH = re.compile(r"^/board/([\w-]+)/job/([0-9a-f-]{36})(?:/apply)?/?$")
|
|
29
|
+
API = "https://api.ats.welcometothejungle.com/api/v1/board/organizations/{org}/jobs/{job}"
|
|
30
|
+
NEW_SUBMIT = "[data-testid=submit-application-button]"
|
|
31
|
+
CAPTCHA = "iframe[title=reCAPTCHA]"
|
|
32
|
+
CHALLENGE = "iframe[src*='recaptcha/api2/bframe']"
|
|
33
|
+
FORM_TIMEOUT_MS = 30_000
|
|
34
|
+
UPLOAD_TIMEOUT_MS = 30_000
|
|
35
|
+
CAPTCHA_TIMEOUT_MS = 20_000
|
|
36
|
+
SUBMIT_TIMEOUT_MS = 90_000
|
|
37
|
+
YES_NO = ["Yes", "No"]
|
|
38
|
+
PHOTO = ["image/png", "image/jpeg", "image/gif"] # the photo's input declares no type
|
|
39
|
+
# The newer form's personal fields, always drawn: (name, label, type, required)
|
|
40
|
+
PERSONAL = [
|
|
41
|
+
("firstname", "First name", FieldType.TEXT, True),
|
|
42
|
+
("lastname", "Last name", FieldType.TEXT, True),
|
|
43
|
+
("email", "Email address", FieldType.TEXT, True),
|
|
44
|
+
("linkedin_url", "LinkedIn profile URL", FieldType.URL, False),
|
|
45
|
+
("phone_number", "Phone number", FieldType.PHONE, False),
|
|
46
|
+
("personal_website_url", "Personal website URL", FieldType.URL, False),
|
|
47
|
+
]
|
|
48
|
+
QUESTION_KINDS = {"text": FieldType.TEXT, "boolean": FieldType.CHOICE, "document": FieldType.FILE}
|
|
49
|
+
|
|
50
|
+
# What each control holds, by name: a value, a select's option text, a box as Yes/No, a file's
|
|
51
|
+
# name; a newer form's boolean question, by its place among them, as its selected button.
|
|
52
|
+
_READ_BACK = """(form, controls) => {
|
|
53
|
+
const out = {};
|
|
54
|
+
const booleans = [...form.querySelectorAll("[data-testid=board-boolean-field-true]")]
|
|
55
|
+
.map(b => b.parentElement);
|
|
56
|
+
for (const c of controls) {
|
|
57
|
+
if (c.kind === "boolean") {
|
|
58
|
+
const selected = booleans[c.place]?.querySelector("[data-selected=true]");
|
|
59
|
+
out[c.id] = selected?.textContent.trim() ?? "";
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const el = form.querySelector(`[name="${c.name}"]:not([type=hidden])`);
|
|
63
|
+
if (!el) continue;
|
|
64
|
+
if (c.kind === "select") out[c.id] = el.selectedOptions[0]?.textContent.trim() ?? "";
|
|
65
|
+
else if (c.kind === "checkbox") out[c.id] = el.checked ? "Yes" : "No";
|
|
66
|
+
else if (c.kind === "file") out[c.id] = el.files[0]?.name ?? "";
|
|
67
|
+
else if (c.kind === "upload") {
|
|
68
|
+
const done = el.closest("[data-testid^=file-input-]")?.querySelector("svg.name-check");
|
|
69
|
+
out[c.id] = done?.parentElement.textContent.trim().replace(/ \\([^()]*\\)$/, "") ?? "";
|
|
70
|
+
}
|
|
71
|
+
else out[c.id] = el.value ?? "";
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}"""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class _Control:
|
|
79
|
+
field: Field
|
|
80
|
+
kind: str # text | select | checkbox | file | boolean | upload
|
|
81
|
+
name: str # the control's `name`
|
|
82
|
+
place: int = 0 # a boolean question's place among the form's boolean questions
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class WelcomeKit:
|
|
86
|
+
name = "welcomekit"
|
|
87
|
+
known_gaps: dict[str, Gap] = {}
|
|
88
|
+
side_effects = [
|
|
89
|
+
SideEffect("upload", "hiring_system", "the newer form uploads a file once it is attached"),
|
|
90
|
+
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
def __init__(self) -> None:
|
|
94
|
+
self._controls: list[_Control] = []
|
|
95
|
+
self._new = False
|
|
96
|
+
|
|
97
|
+
def detect(self, url: str, page: Page | None) -> bool:
|
|
98
|
+
return _old(url) is not None or _new(url) is not None
|
|
99
|
+
|
|
100
|
+
def posting(self, url: str) -> str:
|
|
101
|
+
if old := _old(url):
|
|
102
|
+
return f"{OLD_ORIGIN}{OLD_PATH}?o={old[0]}&j={old[1]}"
|
|
103
|
+
if new := _new(url):
|
|
104
|
+
return f"{NEW_ORIGIN}/board/{new[0]}/job/{new[1]}/apply"
|
|
105
|
+
return url
|
|
106
|
+
|
|
107
|
+
def request_read(self, url: str) -> list[Field] | None:
|
|
108
|
+
try:
|
|
109
|
+
if _old(url):
|
|
110
|
+
controls = _old_controls(_get(self.posting(url)))
|
|
111
|
+
elif new := _new(url):
|
|
112
|
+
controls = _new_controls(json.loads(_get(API.format(org=new[0], job=new[1]))))
|
|
113
|
+
else:
|
|
114
|
+
return None
|
|
115
|
+
except urllib.error.HTTPError, OSError, ValueError:
|
|
116
|
+
return None
|
|
117
|
+
return [c.field for c in controls] if controls else None
|
|
118
|
+
|
|
119
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
120
|
+
if new := _new(page.url):
|
|
121
|
+
self._new = True
|
|
122
|
+
if not urlsplit(page.url).path.rstrip("/").endswith("/apply"):
|
|
123
|
+
page.goto(self.posting(page.url))
|
|
124
|
+
api = API.format(org=new[0], job=new[1])
|
|
125
|
+
data = page.evaluate(_FETCH, api)
|
|
126
|
+
if data is None or not (controls := _new_controls(data)):
|
|
127
|
+
raise Unsupported("posting_gone: the job is not published")
|
|
128
|
+
try:
|
|
129
|
+
page.locator(NEW_SUBMIT).wait_for(timeout=FORM_TIMEOUT_MS)
|
|
130
|
+
except Timeout:
|
|
131
|
+
raise Unsupported("posting_gone: no application form on the page") from None
|
|
132
|
+
for c in controls: # each declared question is drawn
|
|
133
|
+
if c.kind == "boolean":
|
|
134
|
+
page.locator("[data-testid=board-boolean-field-true]").nth(c.place).wait_for(
|
|
135
|
+
state="attached", timeout=FORM_TIMEOUT_MS
|
|
136
|
+
)
|
|
137
|
+
elif c.kind in ("text", "upload") and c.name.startswith("custom_questions"):
|
|
138
|
+
page.locator(f"[name='{c.name}']").wait_for(
|
|
139
|
+
state="attached", timeout=FORM_TIMEOUT_MS
|
|
140
|
+
)
|
|
141
|
+
else:
|
|
142
|
+
try:
|
|
143
|
+
page.locator(OLD).wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
|
|
144
|
+
except Timeout:
|
|
145
|
+
raise Unsupported("posting_gone: no application form on the page") from None
|
|
146
|
+
controls = _old_controls(page.content())
|
|
147
|
+
self._controls = controls
|
|
148
|
+
return [c.field for c in controls]
|
|
149
|
+
|
|
150
|
+
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
151
|
+
form = page.locator("form").first if self._new else page.locator(OLD)
|
|
152
|
+
for c in self._controls:
|
|
153
|
+
if c.field.id not in answers or c.field.unfillable:
|
|
154
|
+
continue
|
|
155
|
+
value = answers[c.field.id]
|
|
156
|
+
value = value if isinstance(value, str) else value[0]
|
|
157
|
+
control = form.locator(f"[name='{c.name}']:not([type=hidden])")
|
|
158
|
+
match c.kind:
|
|
159
|
+
case "select":
|
|
160
|
+
control.select_option(label=value)
|
|
161
|
+
case "checkbox": # drawn over by its iCheck box: its label toggles it
|
|
162
|
+
if control.is_checked() != (value == "Yes"):
|
|
163
|
+
form.locator(f"label[for='{control.get_attribute('id')}']").click()
|
|
164
|
+
case "file":
|
|
165
|
+
control.set_input_files(value)
|
|
166
|
+
case "upload": # the file goes to storage at once; a ticked badge then names it
|
|
167
|
+
control.set_input_files(value)
|
|
168
|
+
name = value.rsplit("/", 1)[-1]
|
|
169
|
+
input_ = page.locator(f"input[name='{c.name}']")
|
|
170
|
+
widget = form.locator("[data-testid^=file-input-]").filter(has=input_)
|
|
171
|
+
badge = widget.locator("*:has(> svg.name-check)", has_text=name)
|
|
172
|
+
badge.first.wait_for(timeout=UPLOAD_TIMEOUT_MS)
|
|
173
|
+
case "boolean":
|
|
174
|
+
testid = "true" if value == "Yes" else "false"
|
|
175
|
+
buttons = form.locator(f"[data-testid=board-boolean-field-{testid}]")
|
|
176
|
+
button = buttons.nth(c.place)
|
|
177
|
+
if button.get_attribute("data-selected") != "true":
|
|
178
|
+
button.click()
|
|
179
|
+
case _:
|
|
180
|
+
control.fill(value)
|
|
181
|
+
|
|
182
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
183
|
+
form = page.locator("form").first if self._new else page.locator(OLD)
|
|
184
|
+
controls = [
|
|
185
|
+
{"id": c.field.id, "name": c.name, "kind": c.kind, "place": c.place}
|
|
186
|
+
for c in self._controls
|
|
187
|
+
if not c.field.unfillable
|
|
188
|
+
]
|
|
189
|
+
values = form.evaluate(_READ_BACK, controls)
|
|
190
|
+
return {k: v for k, v in values.items() if v not in (None, "")}
|
|
191
|
+
|
|
192
|
+
def submit(self, page: Page) -> str:
|
|
193
|
+
"""The older form: ticks the reCAPTCHA box, `Blocked` on an image challenge, posts the
|
|
194
|
+
form and lands on the page after it, the evidence. The newer form: clicks Submit
|
|
195
|
+
application; the evidence is the answer to the apply POST."""
|
|
196
|
+
if self._new:
|
|
197
|
+
return _submit_new(page)
|
|
198
|
+
captcha = page.locator(CAPTCHA)
|
|
199
|
+
if captcha.count():
|
|
200
|
+
captcha.content_frame.locator("#recaptcha-anchor").click()
|
|
201
|
+
token = page.locator("#g-recaptcha-response")
|
|
202
|
+
try:
|
|
203
|
+
page.wait_for_function(
|
|
204
|
+
"el => el.value !== ''", arg=token.element_handle(), timeout=CAPTCHA_TIMEOUT_MS
|
|
205
|
+
)
|
|
206
|
+
except Timeout:
|
|
207
|
+
if page.locator(CHALLENGE).is_visible():
|
|
208
|
+
raise Blocked("captcha: reCAPTCHA image challenge") from None
|
|
209
|
+
raise
|
|
210
|
+
with page.expect_navigation(timeout=SUBMIT_TIMEOUT_MS):
|
|
211
|
+
page.locator(f"{OLD} [type=submit]").click()
|
|
212
|
+
if page.locator(OLD).count():
|
|
213
|
+
raise RuntimeError(f"the form was not taken: {_errors(page)}")
|
|
214
|
+
return page.url
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# The newer form's job, fetched from inside the page so it is the browser that asks.
|
|
218
|
+
_FETCH = "url => fetch(url).then(r => r.ok ? r.json() : null)"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _old(url: str) -> tuple[str, str] | None:
|
|
222
|
+
"""The organization and job references, from `welcomekit.co/candidates/new?o=..&j=..`."""
|
|
223
|
+
parts = urlsplit(url)
|
|
224
|
+
query = parse_qs(parts.query)
|
|
225
|
+
o, j = query.get("o", [""])[0], query.get("j", [""])[0]
|
|
226
|
+
ok = OLD_HOST.match(parts.hostname or "") and parts.path.rstrip("/") == OLD_PATH
|
|
227
|
+
return (o, j) if ok and o and j else None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _new(url: str) -> tuple[str, str] | None:
|
|
231
|
+
"""The organization slug and the job id, from `ats.welcometothejungle.com/board/..`."""
|
|
232
|
+
parts = urlsplit(url)
|
|
233
|
+
match = NEW_PATH.match(parts.path)
|
|
234
|
+
return (match[1], match[2]) if parts.hostname == NEW_HOST and match else None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _get(url: str) -> str:
|
|
238
|
+
request = urllib.request.Request(url, headers={"User-Agent": "atsfill"})
|
|
239
|
+
with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
|
|
240
|
+
return response.read().decode()
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _old_controls(source: str) -> list[_Control]:
|
|
244
|
+
"""The older form's controls, in the page's order; an unknown job's page has no form."""
|
|
245
|
+
page = html.parse(source)
|
|
246
|
+
form = page.find(lambda n: n.tag == "form" and n.attrs.get("id") == "new_candidate")
|
|
247
|
+
if form is None:
|
|
248
|
+
return []
|
|
249
|
+
labels = {n.attrs["for"]: n for n in form.walk() if n.tag == "label" and "for" in n.attrs}
|
|
250
|
+
out: list[_Control] = []
|
|
251
|
+
for node in form.walk():
|
|
252
|
+
name = node.attrs.get("name", "")
|
|
253
|
+
kind = node.attrs.get("type", "text") if node.tag == "input" else node.tag
|
|
254
|
+
if node.tag not in ("input", "select", "textarea") or not name.startswith("candidate["):
|
|
255
|
+
continue
|
|
256
|
+
if kind == "hidden" or node.closest(lambda n: "hidden" in n.classes()):
|
|
257
|
+
continue
|
|
258
|
+
field_id = _id(name)
|
|
259
|
+
own = labels.get(node.attrs.get("id", ""))
|
|
260
|
+
label = _text(own) if own is not None else _group_label(node, field_id)
|
|
261
|
+
required = "required" in node.attrs
|
|
262
|
+
match kind:
|
|
263
|
+
case "text" | "email" | "textarea":
|
|
264
|
+
field, kept = Field(field_id, label, FieldType.TEXT, required), "text"
|
|
265
|
+
case "tel":
|
|
266
|
+
field, kept = Field(field_id, label, FieldType.PHONE, required), "text"
|
|
267
|
+
case "select":
|
|
268
|
+
options = [
|
|
269
|
+
(o, " ".join(o.text().split()))
|
|
270
|
+
for o in node.walk()
|
|
271
|
+
if o.tag == "option" and o.attrs.get("value")
|
|
272
|
+
]
|
|
273
|
+
default = next((t for o, t in options if "selected" in o.attrs), None)
|
|
274
|
+
texts = [t for _, t in options]
|
|
275
|
+
field = Field(field_id, label, FieldType.CHOICE, required, texts, default=default)
|
|
276
|
+
kept = "select"
|
|
277
|
+
case "file":
|
|
278
|
+
accept = node.attrs.get("accept")
|
|
279
|
+
types = accept.split(",") if accept else PHOTO if field_id == "image" else None
|
|
280
|
+
field, kept = Field(field_id, label, FieldType.FILE, required, accept=types), "file"
|
|
281
|
+
case "checkbox":
|
|
282
|
+
field = Field(field_id, label, FieldType.CHOICE, required, YES_NO)
|
|
283
|
+
kept = "checkbox"
|
|
284
|
+
case _:
|
|
285
|
+
reason = f"unhandled_rendering: {field_id} is a {kind}"
|
|
286
|
+
field = Field(field_id, label, FieldType.TEXT, required, unfillable=reason)
|
|
287
|
+
kept = "text"
|
|
288
|
+
out.append(_Control(field, kept, name))
|
|
289
|
+
return out
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _id(name: str) -> str:
|
|
293
|
+
"""`candidate[profile_attributes][firstname]` is `firstname`, `candidate[answers[0][answer]]`
|
|
294
|
+
`answers.0`."""
|
|
295
|
+
parts = re.findall(r"\w+", name)[1:]
|
|
296
|
+
parts = [p for p in parts if p != "profile_attributes"]
|
|
297
|
+
return ".".join(parts[:-1] if parts[0] == "answers" else parts)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _group_label(node: html.Node, field_id: str) -> str:
|
|
301
|
+
"""An address part's label: its group's, with the part's placeholder; the country list,
|
|
302
|
+
which has none and lists its countries in English, `Country`."""
|
|
303
|
+
group = node.closest(lambda n: "address" in n.classes() and "form-group" in n.classes())
|
|
304
|
+
label = group.find(lambda n: n.tag == "label") if group else None
|
|
305
|
+
placeholder = node.attrs.get("placeholder", "Country" if node.tag == "select" else "")
|
|
306
|
+
if label is None:
|
|
307
|
+
return placeholder or field_id
|
|
308
|
+
return f"{_text(label)}: {placeholder}" if placeholder else _text(label)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _text(label: html.Node) -> str:
|
|
312
|
+
return " ".join(label.text().split()).removesuffix("*").strip()
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _new_controls(data: dict) -> list[_Control]:
|
|
316
|
+
"""The newer form's controls from its job's JSON: the personal fields, the résumé, then the
|
|
317
|
+
questions by their order; none for a job that is not published."""
|
|
318
|
+
job = data.get("job") or {}
|
|
319
|
+
if job.get("status") != "published":
|
|
320
|
+
return []
|
|
321
|
+
out = [_Control(Field(n, t, k, r), "text", n) for n, t, k, r in PERSONAL]
|
|
322
|
+
cv = Field("cv", "Resume", FieldType.FILE, True, accept=[".pdf"])
|
|
323
|
+
out.append(_Control(cv, "upload", "cv"))
|
|
324
|
+
booleans = 0
|
|
325
|
+
questions = sorted(job.get("questions") or [], key=lambda q: q.get("order") or 0)
|
|
326
|
+
for n, q in enumerate(questions):
|
|
327
|
+
name = f"custom_questions.{n}.answer"
|
|
328
|
+
field_id, label, required = f"custom_questions.{n}", q["label"].strip(), q["required"]
|
|
329
|
+
match q["type"]:
|
|
330
|
+
case "boolean":
|
|
331
|
+
field = Field(field_id, label, FieldType.CHOICE, required, YES_NO)
|
|
332
|
+
out.append(_Control(field, "boolean", name, booleans))
|
|
333
|
+
booleans += 1
|
|
334
|
+
case "document":
|
|
335
|
+
field = Field(field_id, label, FieldType.FILE, required, accept=[".pdf"])
|
|
336
|
+
out.append(_Control(field, "upload", name))
|
|
337
|
+
case "text":
|
|
338
|
+
out.append(_Control(Field(field_id, label, FieldType.TEXT, required), "text", name))
|
|
339
|
+
case other:
|
|
340
|
+
reason = f"unhandled_rendering: {field_id} is a {other} question"
|
|
341
|
+
field = Field(field_id, label, FieldType.TEXT, required, unfillable=reason)
|
|
342
|
+
out.append(_Control(field, "text", name))
|
|
343
|
+
return out
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _submit_new(page: Page) -> str:
|
|
347
|
+
new = _new(page.url)
|
|
348
|
+
assert new is not None
|
|
349
|
+
path = urlsplit(API.format(org=new[0], job=new[1])).path + "/apply"
|
|
350
|
+
try:
|
|
351
|
+
with page.expect_response(
|
|
352
|
+
lambda r: r.request.method == "POST" and urlsplit(r.url).path == path,
|
|
353
|
+
timeout=SUBMIT_TIMEOUT_MS,
|
|
354
|
+
) as applied:
|
|
355
|
+
page.locator(NEW_SUBMIT).click()
|
|
356
|
+
except Timeout:
|
|
357
|
+
raise RuntimeError(f"nothing posted: {_errors(page)}") from None
|
|
358
|
+
response = applied.value
|
|
359
|
+
if not response.ok:
|
|
360
|
+
raise RuntimeError(f"apply answered {response.status}: {response.text()[:200]}")
|
|
361
|
+
return f"{response.url} {response.status}"
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _errors(page: Page) -> str:
|
|
365
|
+
shown = page.locator(".help-block:visible, .error:visible, [role=alert]:visible")
|
|
366
|
+
return " ".join(" ".join(shown.all_inner_texts()).split())[:200] or "no error shown"
|
|
@@ -420,3 +420,10 @@ account = "none"
|
|
|
420
420
|
hosting = ["hosted"]
|
|
421
421
|
adapter = "jobaffinity"
|
|
422
422
|
scope = { is = "supported", date = 2026-10-28, commit = "02939fd" }
|
|
423
|
+
|
|
424
|
+
[welcomekit]
|
|
425
|
+
kind = "ats"
|
|
426
|
+
account = "none"
|
|
427
|
+
hosting = ["hosted"]
|
|
428
|
+
adapter = "welcomekit"
|
|
429
|
+
scope = { is = "supported", date = 2026-10-29, commit = "238798d" }
|
|
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
|
|
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
|