atsfill 0.0.4.dev18__tar.gz → 0.0.4.dev20__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.dev18 → atsfill-0.0.4.dev20}/PKG-INFO +2 -2
- {atsfill-0.0.4.dev18 → atsfill-0.0.4.dev20}/README.md +1 -1
- {atsfill-0.0.4.dev18 → atsfill-0.0.4.dev20}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev18 → atsfill-0.0.4.dev20}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev18 → atsfill-0.0.4.dev20}/src/atsfill/adapters/__init__.py +8 -1
- atsfill-0.0.4.dev20/src/atsfill/adapters/smartrecruiters.py +408 -0
- {atsfill-0.0.4.dev18 → atsfill-0.0.4.dev20}/src/atsfill/registry.toml +2 -0
- {atsfill-0.0.4.dev18 → atsfill-0.0.4.dev20}/LICENSE +0 -0
- {atsfill-0.0.4.dev18 → atsfill-0.0.4.dev20}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev18 → atsfill-0.0.4.dev20}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev18 → atsfill-0.0.4.dev20}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev18 → atsfill-0.0.4.dev20}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev18 → atsfill-0.0.4.dev20}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev18 → atsfill-0.0.4.dev20}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev18 → atsfill-0.0.4.dev20}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev18 → atsfill-0.0.4.dev20}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev18 → atsfill-0.0.4.dev20}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev18 → atsfill-0.0.4.dev20}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev18 → atsfill-0.0.4.dev20}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev18 → atsfill-0.0.4.dev20}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev18 → atsfill-0.0.4.dev20}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev18 → atsfill-0.0.4.dev20}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev18 → atsfill-0.0.4.dev20}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev18 → atsfill-0.0.4.dev20}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev18 → atsfill-0.0.4.dev20}/src/atsfill/py.typed +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.dev20
|
|
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
|
-
   
|
|
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
|
-
   
|
|
32
|
+
    
|
|
33
33
|
<!-- /badges -->
|
|
34
34
|
|
|
35
35
|
| System | Account needed | `read` without a browser |
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
from atsfill.adapters.ashby import Ashby
|
|
2
2
|
from atsfill.adapters.greenhouse import Greenhouse
|
|
3
|
+
from atsfill.adapters.smartrecruiters import SmartRecruiters
|
|
3
4
|
from atsfill.adapters.teamtailor import Teamtailor
|
|
4
5
|
from atsfill.adapters.wttj import WelcomeToTheJungle
|
|
5
6
|
from atsfill.core.adapter import Adapter
|
|
6
7
|
|
|
7
|
-
ADAPTERS: list[Adapter] = [
|
|
8
|
+
ADAPTERS: list[Adapter] = [
|
|
9
|
+
Ashby(),
|
|
10
|
+
Teamtailor(),
|
|
11
|
+
Greenhouse(),
|
|
12
|
+
WelcomeToTheJungle(),
|
|
13
|
+
SmartRecruiters(),
|
|
14
|
+
]
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"""SmartRecruiters (`jobs.smartrecruiters.com` job pages and their One Click apply form), as
|
|
2
|
+
docs/adapters/smartrecruiters.md observes.
|
|
3
|
+
|
|
4
|
+
The form is two pages: the profile, then the screening questions and consents. The second
|
|
5
|
+
page's questions come from the page's own `__OC_CONTEXT__`, so `read_form` lists them without
|
|
6
|
+
leaving the first page. Nothing but the resume reaches SmartRecruiters before submit, so the
|
|
7
|
+
read-back is the page: the profile as it stood when `fill` left it, then the second page.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from contextlib import suppress
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.parse import urlsplit
|
|
15
|
+
|
|
16
|
+
from atsfill.core.adapter import Unsupported
|
|
17
|
+
from atsfill.core.browser import Locator, Page, Timeout
|
|
18
|
+
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
19
|
+
|
|
20
|
+
HOSTS = ("jobs.smartrecruiters.com", "www.smartr.me")
|
|
21
|
+
JOB = re.compile(r"^/([^/]+)/(\d+)")
|
|
22
|
+
ONECLICK = re.compile(r"^/oneclick-ui/company/([^/]+)/publication/([^/]+)")
|
|
23
|
+
APPLY_LINK = "a[href*='/oneclick-ui/company/']"
|
|
24
|
+
QUESTIONS = "sr-screening-questions-form"
|
|
25
|
+
CONTAINER = f"{QUESTIONS} [data-test=question-container]"
|
|
26
|
+
FORM_TIMEOUT_MS = 30_000
|
|
27
|
+
COOKIE_TIMEOUT_MS = 5_000
|
|
28
|
+
OPTION_TIMEOUT_MS = 10_000
|
|
29
|
+
UPLOAD_TIMEOUT_MS = 30_000
|
|
30
|
+
SUBMIT_TIMEOUT_MS = 90_000
|
|
31
|
+
NEXT_TIMEOUT_MS = 8_000
|
|
32
|
+
NEXT_TRIES = 3
|
|
33
|
+
YES_NO = ["Yes", "No"]
|
|
34
|
+
|
|
35
|
+
# The profile page: (field id, the input, the element carrying `label` and `required`).
|
|
36
|
+
PROFILE = [
|
|
37
|
+
("first_name", "input#first-name-input", "spl-input#first-name-input"),
|
|
38
|
+
("last_name", "input#last-name-input", "spl-input#last-name-input"),
|
|
39
|
+
("email", "input#email-input", "spl-input#email-input"),
|
|
40
|
+
("confirm_email", "input#confirm-email-input", "spl-input#confirm-email-input"),
|
|
41
|
+
("location", "oc-location-autocomplete input", "oc-location-autocomplete spl-autocomplete"),
|
|
42
|
+
("phone", "oc-phone-number input[type=tel]", "oc-phone-number spl-phone-field"),
|
|
43
|
+
("linkedin", "input#linkedin-input", "spl-input#linkedin-input"),
|
|
44
|
+
("facebook", "input#facebook-input", "spl-input#facebook-input"),
|
|
45
|
+
("x", "input#twitter-input", "spl-input#twitter-input"),
|
|
46
|
+
("website", "input#website-input", "spl-input#website-input"),
|
|
47
|
+
("resume", "oc-resume-upload input[type=file]", "oc-resume-upload"),
|
|
48
|
+
(
|
|
49
|
+
"message",
|
|
50
|
+
"textarea#hiring-manager-message-input",
|
|
51
|
+
"spl-textarea#hiring-manager-message-input",
|
|
52
|
+
),
|
|
53
|
+
]
|
|
54
|
+
PROFILE_TYPES = {
|
|
55
|
+
"location": FieldType.TYPEAHEAD,
|
|
56
|
+
"phone": FieldType.PHONE,
|
|
57
|
+
"resume": FieldType.FILE,
|
|
58
|
+
}
|
|
59
|
+
# Sections of the profile that take entries through their own sub-form.
|
|
60
|
+
ENTRIES = ("oc-experience", "oc-education")
|
|
61
|
+
# Screening question types: one field per entry of the question's `fields`.
|
|
62
|
+
FIELD_TYPES = {
|
|
63
|
+
"text": FieldType.TEXT,
|
|
64
|
+
"textarea": FieldType.TEXT,
|
|
65
|
+
"radio": FieldType.CHOICE,
|
|
66
|
+
"select": FieldType.CHOICE,
|
|
67
|
+
"checkbox": FieldType.CHOICE,
|
|
68
|
+
}
|
|
69
|
+
SKIPPED = ("info",) # text with no input
|
|
70
|
+
OPTIONAL_ONLY = ("files", "languages") # left out when optional, unsupported when required
|
|
71
|
+
|
|
72
|
+
_CONTEXT = """() => {
|
|
73
|
+
const s = [...document.scripts].find(s => s.textContent.includes("__OC_CONTEXT__"));
|
|
74
|
+
if (!s) return null;
|
|
75
|
+
const t = s.textContent;
|
|
76
|
+
return JSON.parse(t.slice(t.indexOf("{"), t.lastIndexOf("}") + 1));
|
|
77
|
+
}"""
|
|
78
|
+
|
|
79
|
+
# Which question each container on the questions page holds, in page order. A text, select or
|
|
80
|
+
# checkbox carries `question_<id>`; a radio group or an EEO pair does not, and is the next
|
|
81
|
+
# question of its type: a hidden conditional question has no container.
|
|
82
|
+
_CONTAINERS = """(form, questions) => {
|
|
83
|
+
const root = form.shadowRoot ?? form;
|
|
84
|
+
let at = 0;
|
|
85
|
+
return [...root.querySelectorAll("[data-test=question-container]")].map(box => {
|
|
86
|
+
const marked = box.querySelector("[id^=question_], [name^=question_]");
|
|
87
|
+
const mark = [marked?.id, marked?.getAttribute("name")].find(v => v?.startsWith("question_"));
|
|
88
|
+
const id = mark?.replace(/^question_/, "");
|
|
89
|
+
const FIELD = "SR-QUESTION-FIELD-";
|
|
90
|
+
const field = [...box.querySelectorAll("*")].find(e => e.tagName.startsWith(FIELD));
|
|
91
|
+
const type = field ? field.tagName.slice(FIELD.length).toLowerCase() : null;
|
|
92
|
+
const i = questions.findIndex((q, n) => n >= at && (id ? q.id === id : q.type === type));
|
|
93
|
+
if (i < 0) return null;
|
|
94
|
+
at = i + 1;
|
|
95
|
+
return questions[i].id;
|
|
96
|
+
});
|
|
97
|
+
}"""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class SmartRecruiters:
|
|
101
|
+
name = "smartrecruiters"
|
|
102
|
+
side_effects = [
|
|
103
|
+
SideEffect(
|
|
104
|
+
"upload", "hiring_system", "an attached resume is stored by SmartRecruiters at once"
|
|
105
|
+
),
|
|
106
|
+
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
def __init__(self) -> None:
|
|
110
|
+
self._fields: dict[str, Field] = {}
|
|
111
|
+
self._questions: dict[str, dict[str, Any]] = {} # by question id
|
|
112
|
+
self._conditions: list[dict[str, Any]] = []
|
|
113
|
+
self._consents: dict[str, str] = {} # field id: the consent box's label
|
|
114
|
+
self._profile: dict[str, Answer] = {} # the profile's read-back, taken before leaving it
|
|
115
|
+
|
|
116
|
+
def detect(self, url: str, page: Page | None) -> bool:
|
|
117
|
+
return _key(url) is not None
|
|
118
|
+
|
|
119
|
+
def posting(self, url: str) -> str:
|
|
120
|
+
return _key(url) or url
|
|
121
|
+
|
|
122
|
+
def read_api(self, url: str) -> list[Field] | None:
|
|
123
|
+
return None # the form's host answers 403 to anything but a browser
|
|
124
|
+
|
|
125
|
+
def read_form(self, page: Page) -> list[Field]:
|
|
126
|
+
if not ONECLICK.match(urlsplit(page.url).path):
|
|
127
|
+
link = page.locator(APPLY_LINK).first
|
|
128
|
+
try:
|
|
129
|
+
link.wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
|
|
130
|
+
except Timeout:
|
|
131
|
+
raise Unsupported("posting_gone: no apply link on the job page") from None
|
|
132
|
+
page.goto(link.get_attribute("href") or "")
|
|
133
|
+
try:
|
|
134
|
+
page.locator(PROFILE[0][1]).wait_for(timeout=FORM_TIMEOUT_MS)
|
|
135
|
+
except Timeout:
|
|
136
|
+
raise Unsupported("posting_gone: no application form on the page") from None
|
|
137
|
+
_decline_cookies(page)
|
|
138
|
+
for section in ENTRIES:
|
|
139
|
+
if page.locator(f"{section} [data-test=section-required-mark]").count():
|
|
140
|
+
raise Unsupported(f"form_shape: {section.removeprefix('oc-')} entries are required")
|
|
141
|
+
context = page.evaluate(_CONTEXT) or {}
|
|
142
|
+
screening = context.get("screeningConfiguration") or {}
|
|
143
|
+
self._questions = {q["id"]: q for q in screening.get("questions") or []}
|
|
144
|
+
self._conditions = screening.get("conditionalConfigurations") or []
|
|
145
|
+
fields = [f for f in (_profile_field(page, *row) for row in PROFILE) if f]
|
|
146
|
+
for question in self._questions.values():
|
|
147
|
+
fields += self._question_fields(question)
|
|
148
|
+
self._consents = {}
|
|
149
|
+
for scope in (context.get("consent") or {}).get("consentScopeConfigs") or []:
|
|
150
|
+
if scope.get("checkboxRequired") is False: # a notice with no box
|
|
151
|
+
continue
|
|
152
|
+
field_id = f"consent.{scope['scope']}"
|
|
153
|
+
self._consents[field_id] = _plain(scope["label"])
|
|
154
|
+
fields.append(Field(field_id, self._consents[field_id], FieldType.CHOICE,
|
|
155
|
+
bool(scope.get("required")), YES_NO)) # fmt: skip
|
|
156
|
+
self._fields = {f.id: f for f in fields}
|
|
157
|
+
return fields
|
|
158
|
+
|
|
159
|
+
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
160
|
+
_decline_cookies(page)
|
|
161
|
+
for field_id, selector, _ in PROFILE:
|
|
162
|
+
if field_id in answers:
|
|
163
|
+
_fill_profile(page, field_id, selector, answers[field_id])
|
|
164
|
+
self._profile = _read_profile(page)
|
|
165
|
+
_next(page)
|
|
166
|
+
for question_id in self._questions: # in page order: a trigger before its conditional
|
|
167
|
+
box = self._container(page, question_id)
|
|
168
|
+
if box is None:
|
|
169
|
+
continue
|
|
170
|
+
for field_id, field in self._question_answers(question_id):
|
|
171
|
+
if field_id in answers:
|
|
172
|
+
_fill_question(box, self._questions[question_id], field, answers[field_id])
|
|
173
|
+
for field_id, box in self._consent_boxes(page).items():
|
|
174
|
+
if field_id in answers and box.is_checked() != (answers[field_id] == "Yes"):
|
|
175
|
+
box.click()
|
|
176
|
+
|
|
177
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
178
|
+
values: dict[str, Answer] = dict(self._profile)
|
|
179
|
+
for question_id, question in self._questions.items():
|
|
180
|
+
box = self._container(page, question_id)
|
|
181
|
+
if box is None:
|
|
182
|
+
continue
|
|
183
|
+
for field_id, field in self._question_answers(question_id):
|
|
184
|
+
if (value := _read_question(box, question, field)) is not None:
|
|
185
|
+
values[field_id] = value
|
|
186
|
+
for field_id, box in self._consent_boxes(page).items():
|
|
187
|
+
values[field_id] = "Yes" if box.is_checked() else "No"
|
|
188
|
+
return {k: v for k, v in values.items() if v not in (None, "", [])}
|
|
189
|
+
|
|
190
|
+
def submit(self, page: Page) -> str | None:
|
|
191
|
+
before = page.url
|
|
192
|
+
page.get_by_role("button", name="Submit", exact=True).first.click()
|
|
193
|
+
page.wait_for_url(lambda url: url != before, timeout=SUBMIT_TIMEOUT_MS)
|
|
194
|
+
return page.url
|
|
195
|
+
|
|
196
|
+
def _question_fields(self, question: dict[str, Any]) -> list[Field]:
|
|
197
|
+
kind = question["type"]
|
|
198
|
+
if kind in SKIPPED or (kind in OPTIONAL_ONLY and not question["required"]):
|
|
199
|
+
return []
|
|
200
|
+
label = _plain(question["label"])
|
|
201
|
+
if condition := self._condition(question["id"]):
|
|
202
|
+
trigger = self._questions[condition["questionId"]]
|
|
203
|
+
picks = [v["label"] for f in trigger["fields"] for v in f["values"]
|
|
204
|
+
if v["id"] in condition["fieldValues"]] # fmt: skip
|
|
205
|
+
label += f" (asked when {_plain(trigger['label'])!r} is {' or '.join(picks)})"
|
|
206
|
+
fields = []
|
|
207
|
+
for entry in question["fields"]:
|
|
208
|
+
field_type = FIELD_TYPES.get(entry["type"])
|
|
209
|
+
if kind == "currency":
|
|
210
|
+
field_type = FieldType.TEXT
|
|
211
|
+
if field_type is None or kind in OPTIONAL_ONLY:
|
|
212
|
+
raise Unsupported(f"form_shape: {label!r} is a {kind} question")
|
|
213
|
+
options = [v["label"] for v in entry["values"]] or None
|
|
214
|
+
if kind == "checkbox":
|
|
215
|
+
options = YES_NO
|
|
216
|
+
elif entry.get("multipleChoice"):
|
|
217
|
+
field_type = FieldType.MULTI_CHOICE
|
|
218
|
+
many = len(question["fields"]) > 1
|
|
219
|
+
fields.append(Field(
|
|
220
|
+
f"{question['id']}.{entry['id']}" if many else question["id"],
|
|
221
|
+
f"{label}: {_plain(entry['label'])}" if many else label,
|
|
222
|
+
field_type,
|
|
223
|
+
bool(question["required"] and entry.get("required", True)) and not condition,
|
|
224
|
+
options,
|
|
225
|
+
)) # fmt: skip
|
|
226
|
+
return fields
|
|
227
|
+
|
|
228
|
+
def _question_answers(self, question_id: str) -> list[tuple[str, dict[str, Any]]]:
|
|
229
|
+
"""(field id, the question's field) for each field `read_form` listed."""
|
|
230
|
+
entries = self._questions[question_id]["fields"]
|
|
231
|
+
ids = [f"{question_id}.{e['id']}" if len(entries) > 1 else question_id for e in entries]
|
|
232
|
+
return [(i, e) for i, e in zip(ids, entries, strict=True) if i in self._fields]
|
|
233
|
+
|
|
234
|
+
def _condition(self, question_id: str) -> dict[str, Any] | None:
|
|
235
|
+
return next(
|
|
236
|
+
(c for c in self._conditions if question_id in c["conditionalQuestionIds"]), None
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def _consent_boxes(self, page: Page) -> dict[str, Locator]:
|
|
240
|
+
"""Each consent's box, in the order of the scopes: several open with a "Select all"."""
|
|
241
|
+
boxes = page.locator("oc-consent spl-checkbox input[type=checkbox]")
|
|
242
|
+
skip = boxes.count() - len(self._consents)
|
|
243
|
+
if skip not in (0, 1):
|
|
244
|
+
return {}
|
|
245
|
+
return {field_id: boxes.nth(skip + n) for n, field_id in enumerate(self._consents)}
|
|
246
|
+
|
|
247
|
+
def _container(self, page: Page, question_id: str) -> Locator | None:
|
|
248
|
+
form = page.locator(QUESTIONS)
|
|
249
|
+
if not form.count():
|
|
250
|
+
return None
|
|
251
|
+
held = form.first.evaluate(_CONTAINERS, list(self._questions.values()))
|
|
252
|
+
if question_id not in held:
|
|
253
|
+
return None
|
|
254
|
+
return page.locator(CONTAINER).nth(held.index(question_id))
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _key(url: str) -> str | None:
|
|
258
|
+
"""`https://jobs.smartrecruiters.com/<company>/<job id>`, or the One Click form's URL."""
|
|
259
|
+
parts = urlsplit(url)
|
|
260
|
+
if parts.hostname not in HOSTS:
|
|
261
|
+
return None
|
|
262
|
+
if match := ONECLICK.match(parts.path):
|
|
263
|
+
return f"https://jobs.smartrecruiters.com/oneclick-ui/company/{match[1]}/publication/{match[2]}"
|
|
264
|
+
if parts.hostname == HOSTS[0] and (match := JOB.match(parts.path)):
|
|
265
|
+
return f"https://jobs.smartrecruiters.com/{match[1]}/{match[2]}"
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _plain(text: str) -> str:
|
|
270
|
+
"""A label without its markdown: links keep their text, emphasis marks go."""
|
|
271
|
+
text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text)
|
|
272
|
+
return re.sub(r"\s+", " ", text.replace("*", "")).strip()
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _decline_cookies(page: Page) -> None:
|
|
276
|
+
reject = page.locator("#onetrust-reject-all-handler")
|
|
277
|
+
with suppress(Timeout):
|
|
278
|
+
reject.wait_for(state="visible", timeout=COOKIE_TIMEOUT_MS)
|
|
279
|
+
reject.click()
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _profile_field(page: Page, field_id: str, selector: str, host: str) -> Field | None:
|
|
283
|
+
element = page.locator(host).first
|
|
284
|
+
if not page.locator(selector).count() or not element.count():
|
|
285
|
+
return None
|
|
286
|
+
if field_id == "resume":
|
|
287
|
+
label = element.locator("[data-test=section-title]").inner_text()
|
|
288
|
+
required = element.locator("[data-test=section-required-mark]").count() > 0
|
|
289
|
+
accept = page.locator(selector).first.get_attribute("accept") or ""
|
|
290
|
+
accepted = [a.strip() for a in accept.split(",") if a.strip()] or None
|
|
291
|
+
return Field(field_id, label.replace("*", "").strip(), FieldType.FILE, required, None,
|
|
292
|
+
None, accepted) # fmt: skip
|
|
293
|
+
label = element.get_attribute("label") or field_id.replace("_", " ").capitalize()
|
|
294
|
+
required = element.get_attribute("required") is not None
|
|
295
|
+
return Field(field_id, label, PROFILE_TYPES.get(field_id, FieldType.TEXT), required)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _fill_profile(page: Page, field_id: str, selector: str, answer: Answer) -> None:
|
|
299
|
+
value = answer if isinstance(answer, str) else answer[0]
|
|
300
|
+
target = page.locator(selector).first
|
|
301
|
+
match field_id:
|
|
302
|
+
case "resume":
|
|
303
|
+
target.set_input_files(value)
|
|
304
|
+
with suppress(Timeout):
|
|
305
|
+
page.locator("oc-resume-upload .c-spl-file-list-item-name").first.wait_for(
|
|
306
|
+
timeout=UPLOAD_TIMEOUT_MS
|
|
307
|
+
)
|
|
308
|
+
case "location": # takes one of its suggestions: the first for the typed text
|
|
309
|
+
target.click()
|
|
310
|
+
target.press_sequentially(value, delay=30)
|
|
311
|
+
options = page.locator("oc-location-autocomplete spl-select-option")
|
|
312
|
+
option = options.filter(has_text=re.compile(rf"^\s*{re.escape(value)}", re.I)).first
|
|
313
|
+
try:
|
|
314
|
+
option.wait_for(timeout=OPTION_TIMEOUT_MS)
|
|
315
|
+
except Timeout: # another option would switch the field to a country and postal code
|
|
316
|
+
target.press("Escape")
|
|
317
|
+
return
|
|
318
|
+
option.click()
|
|
319
|
+
case "phone": # the number's dial code picks the country
|
|
320
|
+
target.press_sequentially(value, delay=20)
|
|
321
|
+
case _:
|
|
322
|
+
target.fill(value)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _next(page: Page) -> None:
|
|
326
|
+
"""Goes on to the questions page. Never Submit: a posting with no questions submits from
|
|
327
|
+
the profile page. A click right after the last answer can go unheeded, so it is repeated."""
|
|
328
|
+
button = page.get_by_role("button", name="Next", exact=True)
|
|
329
|
+
for _ in range(NEXT_TRIES):
|
|
330
|
+
if not button.count():
|
|
331
|
+
return
|
|
332
|
+
button.first.click()
|
|
333
|
+
with suppress(Timeout):
|
|
334
|
+
page.locator(QUESTIONS).first.wait_for(timeout=NEXT_TIMEOUT_MS)
|
|
335
|
+
return
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _read_profile(page: Page) -> dict[str, Answer]:
|
|
339
|
+
values: dict[str, Answer] = {}
|
|
340
|
+
for field_id, selector, _ in PROFILE:
|
|
341
|
+
target = page.locator(selector).first
|
|
342
|
+
if not target.count():
|
|
343
|
+
continue
|
|
344
|
+
if field_id == "resume":
|
|
345
|
+
names = page.locator("oc-resume-upload .c-spl-file-list-item-name")
|
|
346
|
+
values[field_id] = names.first.inner_text().strip() if names.count() else ""
|
|
347
|
+
elif field_id == "phone": # the field's value: {"internationalFormat": "+1…", …}
|
|
348
|
+
raw = page.locator("oc-phone-number spl-phone-field").first.get_attribute("value")
|
|
349
|
+
with suppress(ValueError, TypeError, AttributeError):
|
|
350
|
+
values[field_id] = json.loads(raw or "{}").get("internationalFormat") or ""
|
|
351
|
+
else:
|
|
352
|
+
values[field_id] = target.input_value()
|
|
353
|
+
return values
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _option_ids(entry: dict[str, Any]) -> dict[str, str]:
|
|
357
|
+
return {v["label"]: v["id"] for v in entry["values"]}
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _inputs(box: Locator, entry: dict[str, Any], question: dict[str, Any]) -> Locator:
|
|
361
|
+
"""The input of one of the question's fields: an EEO pair holds one per field."""
|
|
362
|
+
n = [e["id"] for e in question["fields"]].index(entry["id"])
|
|
363
|
+
return box.locator("input:not([type=checkbox]), textarea").nth(n)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _fill_question(
|
|
367
|
+
box: Locator, question: dict[str, Any], entry: dict[str, Any], answer: Answer
|
|
368
|
+
) -> None:
|
|
369
|
+
values = [answer] if isinstance(answer, str) else answer
|
|
370
|
+
match entry["type"]:
|
|
371
|
+
case "radio":
|
|
372
|
+
box.locator(f"spl-radio[value='{_option_ids(entry).get(values[0], '')}']").click()
|
|
373
|
+
case "checkbox":
|
|
374
|
+
target = box.locator("input[type=checkbox]").first
|
|
375
|
+
if target.is_checked() != (values[0] == "Yes"):
|
|
376
|
+
target.click()
|
|
377
|
+
case "select":
|
|
378
|
+
target = _inputs(box, entry, question)
|
|
379
|
+
for value in values:
|
|
380
|
+
option_id = _option_ids(entry).get(value)
|
|
381
|
+
if option_id is None:
|
|
382
|
+
continue
|
|
383
|
+
target.click()
|
|
384
|
+
target.press_sequentially(value[:20], delay=20)
|
|
385
|
+
option = box.locator(f"spl-select-option[value='{option_id}']").first
|
|
386
|
+
try:
|
|
387
|
+
option.wait_for(timeout=OPTION_TIMEOUT_MS)
|
|
388
|
+
except Timeout:
|
|
389
|
+
target.press("Escape")
|
|
390
|
+
continue
|
|
391
|
+
option.click()
|
|
392
|
+
case _:
|
|
393
|
+
_inputs(box, entry, question).fill(values[0])
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _read_question(box: Locator, question: dict[str, Any], entry: dict[str, Any]) -> Answer | None:
|
|
397
|
+
match entry["type"]:
|
|
398
|
+
case "radio":
|
|
399
|
+
checked = box.locator("spl-radio[aria-checked=true]")
|
|
400
|
+
return checked.first.get_attribute("label") if checked.count() else None
|
|
401
|
+
case "checkbox":
|
|
402
|
+
return "Yes" if box.locator("input[type=checkbox]").first.is_checked() else "No"
|
|
403
|
+
case "select" if entry.get("multipleChoice"): # the picked option ids, comma separated
|
|
404
|
+
picked = box.locator("spl-multiselect-autocomplete").first.get_attribute("value") or ""
|
|
405
|
+
labels = {v: k for k, v in _option_ids(entry).items()}
|
|
406
|
+
return [labels.get(i, i) for i in picked.split(",") if i]
|
|
407
|
+
case _:
|
|
408
|
+
return _inputs(box, entry, question).input_value()
|
|
@@ -77,7 +77,9 @@ kind = "ats"
|
|
|
77
77
|
share = 0.0441
|
|
78
78
|
account = "none"
|
|
79
79
|
hosting = ["hosted"]
|
|
80
|
+
adapter = "smartrecruiters"
|
|
80
81
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
82
|
+
dry_run_verified = { date = 2026-10-20, version = "0.0.3", by = "gate", commit = "d7eb8e7" }
|
|
81
83
|
|
|
82
84
|
[successfactors]
|
|
83
85
|
kind = "ats"
|
|
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
|