atsfill 0.0.4.dev19__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.
Files changed (25) hide show
  1. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev20}/PKG-INFO +2 -2
  2. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev20}/README.md +1 -1
  3. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev20}/pyproject.toml +1 -1
  4. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev20}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev20}/src/atsfill/adapters/__init__.py +8 -1
  6. atsfill-0.0.4.dev20/src/atsfill/adapters/smartrecruiters.py +408 -0
  7. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev20}/src/atsfill/registry.toml +2 -0
  8. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev20}/LICENSE +0 -0
  9. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev20}/src/atsfill/__init__.py +0 -0
  10. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev20}/src/atsfill/adapters/ashby.py +0 -0
  11. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev20}/src/atsfill/adapters/greenhouse.py +0 -0
  12. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev20}/src/atsfill/adapters/teamtailor.py +0 -0
  13. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev20}/src/atsfill/adapters/wttj.py +0 -0
  14. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev20}/src/atsfill/cli.py +0 -0
  15. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev20}/src/atsfill/core/__init__.py +0 -0
  16. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev20}/src/atsfill/core/accounts.py +0 -0
  17. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev20}/src/atsfill/core/adapter.py +0 -0
  18. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev20}/src/atsfill/core/attempts.py +0 -0
  19. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev20}/src/atsfill/core/browser.py +0 -0
  20. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev20}/src/atsfill/core/commands.py +0 -0
  21. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev20}/src/atsfill/core/matching.py +0 -0
  22. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev20}/src/atsfill/core/paths.py +0 -0
  23. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev20}/src/atsfill/core/registry.py +0 -0
  24. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev20}/src/atsfill/core/results.py +0 -0
  25. {atsfill-0.0.4.dev19 → 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.dev19
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
- ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
56
+ ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
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
- ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
32
+ ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
33
33
  <!-- /badges -->
34
34
 
35
35
  | System | Account needed | `read` without a browser |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.4.dev19"
3
+ version = "0.0.4.dev20"
4
4
  description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.4.dev19"
3
+ version = "0.0.4.dev20"
4
4
  description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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] = [Ashby(), Teamtailor(), Greenhouse(), WelcomeToTheJungle()]
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