atsfill 0.0.4.dev19__tar.gz → 0.0.4.dev21__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 (27) hide show
  1. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/PKG-INFO +2 -2
  2. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/README.md +1 -1
  3. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/pyproject.toml +1 -1
  4. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/pyproject.toml.orig +1 -1
  5. atsfill-0.0.4.dev21/src/atsfill/adapters/__init__.py +16 -0
  6. atsfill-0.0.4.dev21/src/atsfill/adapters/oracle.py +628 -0
  7. atsfill-0.0.4.dev21/src/atsfill/adapters/smartrecruiters.py +408 -0
  8. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/src/atsfill/registry.toml +4 -0
  9. atsfill-0.0.4.dev19/src/atsfill/adapters/__init__.py +0 -7
  10. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/LICENSE +0 -0
  11. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/src/atsfill/__init__.py +0 -0
  12. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/src/atsfill/adapters/ashby.py +0 -0
  13. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/src/atsfill/adapters/greenhouse.py +0 -0
  14. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/src/atsfill/adapters/teamtailor.py +0 -0
  15. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/src/atsfill/adapters/wttj.py +0 -0
  16. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/src/atsfill/cli.py +0 -0
  17. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/src/atsfill/core/__init__.py +0 -0
  18. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/src/atsfill/core/accounts.py +0 -0
  19. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/src/atsfill/core/adapter.py +0 -0
  20. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/src/atsfill/core/attempts.py +0 -0
  21. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/src/atsfill/core/browser.py +0 -0
  22. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/src/atsfill/core/commands.py +0 -0
  23. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/src/atsfill/core/matching.py +0 -0
  24. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/src/atsfill/core/paths.py +0 -0
  25. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/src/atsfill/core/registry.py +0 -0
  26. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/src/atsfill/core/results.py +0 -0
  27. {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/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.dev21
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
+ ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![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
+ ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![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.dev21"
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.dev21"
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"
@@ -0,0 +1,16 @@
1
+ from atsfill.adapters.ashby import Ashby
2
+ from atsfill.adapters.greenhouse import Greenhouse
3
+ from atsfill.adapters.oracle import Oracle
4
+ from atsfill.adapters.smartrecruiters import SmartRecruiters
5
+ from atsfill.adapters.teamtailor import Teamtailor
6
+ from atsfill.adapters.wttj import WelcomeToTheJungle
7
+ from atsfill.core.adapter import Adapter
8
+
9
+ ADAPTERS: list[Adapter] = [
10
+ Ashby(),
11
+ Teamtailor(),
12
+ Greenhouse(),
13
+ WelcomeToTheJungle(),
14
+ SmartRecruiters(),
15
+ Oracle(),
16
+ ]
@@ -0,0 +1,628 @@
1
+ """Oracle Recruiting Cloud's Candidate Experience (`*.oraclecloud.com/hcmUI/CandidateExperience`),
2
+ as docs/adapters/oracle.md observes.
3
+
4
+ The form opens only past an email step, and an email the employer's Oracle already knows gets an
5
+ emailed code instead. Reading passes the step with a throwaway address; filling starts over with
6
+ the answer's. Oracle keeps a draft of the application from then on, autosaved as fields change.
7
+ """
8
+
9
+ import re
10
+ import uuid
11
+ from contextlib import suppress
12
+ from dataclasses import replace
13
+ from datetime import date
14
+ from pathlib import PurePath
15
+ from typing import Any
16
+ from urllib.parse import urlsplit
17
+
18
+ from atsfill.core import browser, matching
19
+ from atsfill.core.adapter import Blocked, Unsupported
20
+ from atsfill.core.browser import Locator, Page
21
+ from atsfill.core.results import Answer, Field, FieldType, SideEffect
22
+
23
+ JOB = re.compile(r"/hcmUI/CandidateExperience/[\w-]+/sites/[^/]+/job/(\d+)")
24
+ EMAIL = "email"
25
+ LEGAL = "legalDisclaimer"
26
+ COUNTRY = "country"
27
+ EMAIL_INPUT = "input[type=email]"
28
+ LEGAL_BOX = "legal-disclaimer .apply-flow-input-checkbox__button"
29
+ NEXT = "button[type=submit].apply-flow-pagination__button"
30
+ SUBMIT = ".apply-flow-pagination__submit-button"
31
+ PAGINATION = ".apply-flow-pagination__button"
32
+ PAGE_NUMBERS = ".apply-flow-navigation-pages__link:visible"
33
+ LOADING = ".app-loading-spinner, .cx-select--loading, .apply-flow-block .skeleton"
34
+ CODE_PAGE = ".apply-flow-pin-code-required"
35
+ CAPTCHA = "iframe[src*='hcaptcha.com']"
36
+ MANUALLY = ".quick-apply-flow__manually--link:visible" # past an offer to import a profile
37
+ COOKIES = ".cookie-consent button:visible"
38
+ ATTACHED = ".attachment-upload-button__download, .misc-attachment-list__item-name"
39
+ STEP_TIMEOUT_MS = 30_000
40
+ LIST_TIMEOUT_MS = 3_000
41
+ SETTLE_MS = 1_500
42
+ UPLOAD_TIMEOUT_MS = 30_000
43
+ CHECKBOX = ["Yes", "No"]
44
+ KINDS = {
45
+ "text": FieldType.TEXT,
46
+ "phone": FieldType.PHONE,
47
+ "date": FieldType.DATE,
48
+ "pills": FieldType.CHOICE,
49
+ "radios": FieldType.CHOICE,
50
+ "select": FieldType.CHOICE,
51
+ "check": FieldType.CHOICE,
52
+ "multipills": FieldType.MULTI_CHOICE,
53
+ "checks": FieldType.MULTI_CHOICE,
54
+ "file": FieldType.FILE,
55
+ }
56
+
57
+ # Every field of the form, by block. A row's id is its label's DOM id without the counter Oracle
58
+ # appends on each load (`labelText-lastName-14` is `lastName`); a block of tiles to add
59
+ # (education, experience, skills) is one `tiles` entry.
60
+ _READ = """() => {
61
+ const text = e => (e?.textContent || "").replace(/\\s+/g, " ").trim().replace(/\\s*\\*$/, "");
62
+ const stable = s => s?.replace(/^labelText-/, "").replace(/-\\d+(-label)?$/, "");
63
+ const shown = e => !!e.getClientRects().length;
64
+ const fields = [];
65
+ const add = f => { if (f.id && !fields.some(g => g.id === f.id)) fields.push(f); };
66
+ for (const block of document.querySelectorAll("apply-flow-block")) {
67
+ const kind = [...(block.firstElementChild?.classList || [])]
68
+ .find(c => c.startsWith("apply-flow-block--"))?.slice(18);
69
+ const title = block.querySelector(".apply-flow-block__title");
70
+ const blockRequired = !!title?.classList.contains("apply-flow-block__title--required");
71
+ const blockId = `block:${block.dataset.scrollSpyId}`;
72
+ if (!shown(block) || kind === "profile-import") continue;
73
+ if (kind === "agreements") {
74
+ for (const box of block.querySelectorAll("input[type=checkbox]")) {
75
+ const label = box.closest("label") || block.querySelector(`label[for="${box.id}"]`);
76
+ const id = (box.id || label?.id || "").replace(/-checkbox(-label)?$/, "");
77
+ add({id, kind: "check", label: text(label), required: false});
78
+ }
79
+ continue;
80
+ }
81
+ const files = [...block.querySelectorAll("input[type=file]")];
82
+ for (const input of files) {
83
+ const button = input.closest("resume-upload-button, cover-letter-upload-button");
84
+ const id = button?.tagName === "RESUME-UPLOAD-BUTTON" ? "resume"
85
+ : button ? "coverLetter" : blockId;
86
+ const required = button
87
+ ? !!button.querySelector(".attachment-upload-button__drag-and-drop-label--required")
88
+ : blockRequired;
89
+ const filled = button?.querySelector(".attachment-upload-button__download");
90
+ add({id, kind: "file", label: button ? text(button.querySelector(
91
+ ".attachment-upload-button__label")) : text(title), required,
92
+ accept: input.accept ? input.accept.split(",").map(s => s.trim()) : null,
93
+ value: filled ? text(filled) : null});
94
+ }
95
+ const rows = [...block.querySelectorAll(".input-row")]
96
+ .filter(r => !r.querySelector(".input-row") && shown(r));
97
+ if (!rows.length && !files.length && block.querySelector(
98
+ ".apply-flow-profile-item-tile__new-tile, .apply-flow-profile-item-button--add")) {
99
+ add({id: blockId, kind: "tiles", label: text(title), required: blockRequired});
100
+ continue;
101
+ }
102
+ for (const row of rows) {
103
+ const label = row.querySelector(".input-row__label");
104
+ const named = row.querySelector("[id^=labelText-]");
105
+ const input = row.querySelector("input:not([type=hidden]), textarea");
106
+ let id = stable(named?.id || label?.getAttribute("for") || label?.id || input?.name);
107
+ const field = {id, label: text(row.querySelector(
108
+ ".input-row__linebreak, .input-row__label-text") || label),
109
+ required: !!label?.classList.contains("input-row__label--required")
110
+ || !!row.querySelector("[aria-required=true], [required]")};
111
+ const pills = sel => [...row.querySelectorAll(sel)].map(text);
112
+ if (row.querySelector(".phone-row")) {
113
+ field.kind = "phone";
114
+ field.value = row.querySelector("input[type=tel]").value;
115
+ } else if (row.matches(".datepicker-row")) {
116
+ field.kind = "date";
117
+ } else if (row.querySelector("ul[role=radiogroup]")) {
118
+ field.kind = "pills";
119
+ field.options = pills(".cx-select-pill-name");
120
+ } else if (row.querySelector(".cx-multi-select-pills")) {
121
+ field.kind = "multipills";
122
+ field.options = pills(".cx-select-pill-name");
123
+ } else if (row.querySelector("input[role=combobox]")) {
124
+ field.kind = "select";
125
+ field.value = row.querySelector("input[role=combobox]").value;
126
+ field.geo = row.matches(".geo-hierarchy-form-element");
127
+ field.search = !!row.closest(".address-form") && !field.geo;
128
+ } else if (row.querySelector("input.apply-flow-input-radio-control")) {
129
+ field.kind = "radios";
130
+ field.options = pills(".apply-flow-input-radio__label");
131
+ } else if (row.querySelector(".input-row__control-item input[type=checkbox]")) {
132
+ field.kind = "checks";
133
+ field.options = pills(".apply-flow-input-checkbox__label");
134
+ } else if (input?.matches("textarea, input.input-row__control") && !input.disabled) {
135
+ field.kind = input.type === "email" ? "email" : "text";
136
+ field.value = input.value;
137
+ field.maxLength = input.maxLength > 0 ? input.maxLength : null;
138
+ } else continue;
139
+ add(field);
140
+ }
141
+ }
142
+ return fields;
143
+ }"""
144
+
145
+ # Whether the form is loaded; a dropdown loads only once scrolled into view, so the first one
146
+ # still loading is brought there.
147
+ _SETTLED = """(loading) => {
148
+ const pending = [...document.querySelectorAll(loading)]
149
+ .find(e => e.getClientRects().length && getComputedStyle(e).visibility !== "hidden");
150
+ pending?.scrollIntoView({block: "center"});
151
+ return !pending
152
+ && [...document.querySelectorAll(".apply-flow-block--personal-information-address-v2")]
153
+ .every(block => {
154
+ const country = block.querySelector("address-form-v2 input[role=combobox]");
155
+ return country && country.value && block.querySelector(".address-form .input-row");
156
+ });
157
+ }"""
158
+
159
+ # The selector of the element naming a field's row, found by the read's id rule.
160
+ _ROW = """(id) => {
161
+ const stable = s => s?.replace(/^labelText-/, "").replace(/-\\d+(-label)?$/, "");
162
+ for (const row of document.querySelectorAll(".input-row")) {
163
+ if (row.querySelector(".input-row")) continue;
164
+ const label = row.querySelector(".input-row__label");
165
+ const named = row.querySelector("[id^=labelText-]");
166
+ const input = row.querySelector("input:not([type=hidden]), textarea");
167
+ const rowId = stable(named?.id || label?.getAttribute("for") || label?.id || input?.name);
168
+ if (rowId !== id) continue;
169
+ if (named) return `[id='${named.id}']`;
170
+ if (label?.getAttribute("for")) return `label[for='${label.getAttribute("for")}']`;
171
+ if (label?.id) return `[id='${label.id}']`;
172
+ return `[name='${input.name}']`;
173
+ }
174
+ return null;
175
+ }"""
176
+
177
+ # What the form holds for each field id, in the same terms the read gives its options.
178
+ _READ_BACK = """(ids) => {
179
+ const text = e => (e?.textContent || "").replace(/\\s+/g, " ").trim();
180
+ const stable = s => s?.replace(/^labelText-/, "").replace(/-\\d+(-label)?$/, "");
181
+ const out = {};
182
+ for (const row of document.querySelectorAll(".input-row")) {
183
+ if (row.querySelector(".input-row")) continue;
184
+ const label = row.querySelector(".input-row__label");
185
+ const named = row.querySelector("[id^=labelText-]");
186
+ const input = row.querySelector("input:not([type=hidden]), textarea");
187
+ const id = stable(named?.id || label?.getAttribute("for") || label?.id || input?.name);
188
+ const kind = ids[id];
189
+ if (id in out) continue; // a field shown twice is read where it is filled, the first
190
+ const values = sel => [...row.querySelectorAll(sel)].map(text);
191
+ if (kind === "phone") {
192
+ out[id] = row.querySelector("input[role=combobox]").value
193
+ + row.querySelector("input[type=tel]").value;
194
+ } else if (kind === "date") {
195
+ out[id] = [...row.querySelectorAll("input[role=combobox]")].map(i => i.value);
196
+ } else if (kind === "pills") {
197
+ out[id] = values(".cx-select-pill-section--selected .cx-select-pill-name")[0] ?? null;
198
+ } else if (kind === "multipills") {
199
+ out[id] = values(".cx-select-pill-section--selected .cx-select-pill-name");
200
+ } else if (kind === "select" || kind === "typeahead") {
201
+ out[id] = row.querySelector("input[role=combobox]").value;
202
+ } else if (kind === "radios" || kind === "checks") {
203
+ const picked = [...row.querySelectorAll("input:checked")]
204
+ .map(i => text(row.querySelector(`label[for="${i.id}"]`)));
205
+ out[id] = kind === "radios" ? picked[0] ?? null : picked;
206
+ } else if (kind === "text" || kind === "email") {
207
+ out[id] = input.value;
208
+ }
209
+ }
210
+ for (const box of document.querySelectorAll("apply-flow-block input[type=checkbox]")) {
211
+ const label = box.closest("label") || document.querySelector(`label[for="${box.id}"]`);
212
+ const id = (box.id || label?.id || "").replace(/-checkbox(-label)?$/, "");
213
+ if (ids[id] === "check") out[id] = box.checked ? "Yes" : "No";
214
+ }
215
+ for (const [id, kind] of Object.entries(ids)) {
216
+ if (kind !== "file") continue;
217
+ const host = id === "resume" ? document.querySelector("resume-upload-button")
218
+ : id === "coverLetter" ? document.querySelector("cover-letter-upload-button")
219
+ : document.querySelector(`apply-flow-block[data-scroll-spy-id="${id.slice(6)}"]`);
220
+ const attached = ".attachment-upload-button__download, .misc-attachment-list__item-name";
221
+ out[id] = text(host?.querySelector(attached)) || null;
222
+ }
223
+ return out;
224
+ }"""
225
+
226
+
227
+ class Oracle:
228
+ name = "oracle"
229
+ side_effects = [
230
+ SideEffect(
231
+ "load",
232
+ "hiring_system",
233
+ "the email step starts a draft application in the employer's Oracle, under a"
234
+ " throwaway address to read the form and under the answer's to fill it",
235
+ ),
236
+ SideEffect("fill", "hiring_system", "Oracle autosaves the draft, files included"),
237
+ SideEffect("submit", "employer", "the application reaches the employer"),
238
+ ]
239
+
240
+ def __init__(self) -> None:
241
+ self._fields: dict[str, Field] = {}
242
+ self._kinds: dict[str, str] = {}
243
+ self._pages: dict[str, int] = {} # field id: the page of the form it is on
244
+ self._picked: dict[str, str] = {} # a geography field's id: the item picked in it
245
+ self._months: list[str] = []
246
+ self._job = ""
247
+ self._uploads: dict[str, str] = {}
248
+
249
+ def detect(self, url: str, page: Page | None) -> bool:
250
+ return JOB.search(urlsplit(url).path) is not None
251
+
252
+ def posting(self, url: str) -> str:
253
+ parts = urlsplit(url)
254
+ match = JOB.search(parts.path)
255
+ assert match, url
256
+ return f"https://{parts.hostname}/hcmUI/CandidateExperience/job/{match[1]}"
257
+
258
+ def read_api(self, url: str) -> list[Field] | None:
259
+ return None # the form's metadata is served only past the email step
260
+
261
+ def read_form(self, page: Page) -> list[Field]:
262
+ parts = urlsplit(page.url)
263
+ match = JOB.search(parts.path)
264
+ assert match, page.url # detected on a job URL
265
+ self._job = f"{parts.scheme}://{parts.netloc}{parts.path[: match.end()]}"
266
+ legal = self._email_step(page)
267
+ fields = [Field(EMAIL, "Email Address", FieldType.TEXT, True)]
268
+ if legal:
269
+ fields.append(Field(LEGAL, legal, FieldType.CHOICE, True, CHECKBOX))
270
+ self._pass_email_step(
271
+ page, f"atsfill.read.{uuid.uuid4().hex[:12]}@example.com", bool(legal)
272
+ )
273
+ self._kinds, self._pages = {}, {}
274
+ # The page numbers move between pages without checking the page left.
275
+ for number in range(1, max(len(_page_numbers(page)), 1) + 1):
276
+ _go_to(page, number)
277
+ described = [
278
+ (raw, field)
279
+ for raw in page.evaluate(_READ)
280
+ if raw["id"] not in self._kinds and (field := self._described(page, raw))
281
+ ]
282
+ for raw, field in _geography(described):
283
+ self._kinds[field.id], self._pages[field.id] = raw["kind"], number
284
+ fields.append(field)
285
+ self._fields = {f.id: f for f in fields}
286
+ return fields
287
+
288
+ def fill(self, page: Page, answers: dict[str, Answer]) -> None:
289
+ if LEGAL in self._fields and answers.get(LEGAL) != "Yes":
290
+ raise Unsupported("not_qualified: the form opens only once the terms are accepted")
291
+ page.context.clear_cookies()
292
+ page.goto(self._job + "/apply/email")
293
+ page.evaluate("() => { localStorage.clear(); sessionStorage.clear(); }")
294
+ self._email_step(page)
295
+ email = answers.get(EMAIL)
296
+ assert isinstance(email, str) # required, so answered
297
+ self._pass_email_step(page, email, LEGAL in self._fields)
298
+ for number in sorted(set(self._pages.values())):
299
+ on_page = [i for i in answers if self._pages.get(i) == number]
300
+ if not on_page:
301
+ continue
302
+ _go_to(page, number)
303
+ if COUNTRY in on_page: # the address below it follows the country's format
304
+ self._fill(page, COUNTRY, answers[COUNTRY])
305
+ _settle(page)
306
+ for field_id in [i for i in on_page if i != COUNTRY]:
307
+ self._fill(page, field_id, answers[field_id])
308
+ self._uploads = {
309
+ i: PurePath(a).name
310
+ for i, a in answers.items()
311
+ if self._fields[i].type is FieldType.FILE and isinstance(a, str)
312
+ }
313
+ self._wait_for_uploads(page)
314
+
315
+ def read_back(self, page: Page) -> dict[str, Answer]:
316
+ values: dict[str, Any] = page.evaluate(_READ_BACK, self._kinds)
317
+ values[LEGAL] = "Yes" # the form shows only once the terms were accepted
318
+ for field_id, kind in self._kinds.items():
319
+ value = values.get(field_id)
320
+ if kind == "date" and isinstance(value, list):
321
+ values[field_id] = self._date_value(value)
322
+ picked = self._picked.get(field_id)
323
+ if picked and value and picked != value and picked.split(",")[0].strip() == value:
324
+ values[field_id] = picked # an item that shows only its first part once picked
325
+ return {k: v for k, v in values.items() if v not in (None, "", [])}
326
+
327
+ def submit(self, page: Page) -> str | None:
328
+ _go_to(page, len(_page_numbers(page)) or 1)
329
+ _click(page, page.locator(SUBMIT))
330
+ page.wait_for_url(lambda url: "/apply/" not in url, timeout=STEP_TIMEOUT_MS * 3)
331
+ return page.url
332
+
333
+ def _email_step(self, page: Page) -> str | None:
334
+ """Opens the email step; the terms' label when it asks for them to be accepted."""
335
+ page.goto(self._job + "/apply/email")
336
+ try:
337
+ page.locator(EMAIL_INPUT).wait_for(timeout=STEP_TIMEOUT_MS)
338
+ except browser.Timeout:
339
+ raise Unsupported("posting_gone: no email step on the apply page") from None
340
+ _settle(page)
341
+ terms = page.locator("legal-disclaimer .apply-flow-input-checkbox__label")
342
+ return _text(terms.first.inner_text()) if terms.count() else None
343
+
344
+ def _pass_email_step(self, page: Page, email: str, agree: bool) -> None:
345
+ page.locator(EMAIL_INPUT).fill(email)
346
+ if agree:
347
+ box = page.locator(LEGAL_BOX).first
348
+ if "--checked" not in (box.get_attribute("class") or ""):
349
+ _click(page, box)
350
+ _click(page, page.locator(NEXT).first)
351
+ opened = page.locator(f"apply-flow-block, {CODE_PAGE}, {MANUALLY}")
352
+ try:
353
+ opened.first.wait_for(timeout=STEP_TIMEOUT_MS)
354
+ if page.locator(MANUALLY).count():
355
+ _click(page, page.locator(MANUALLY).first)
356
+ page.locator("apply-flow-block").first.wait_for(timeout=STEP_TIMEOUT_MS)
357
+ except browser.Timeout:
358
+ if page.locator(CAPTCHA).count():
359
+ raise Blocked("captcha: the email step asks for an hCaptcha") from None
360
+ raise Unsupported("form_shape: the email step did not open the form") from None
361
+ if page.locator(CODE_PAGE).count():
362
+ raise Blocked(
363
+ f"emailed_code: {email} is known to this employer's Oracle, which emails it a"
364
+ " code before it shows the form"
365
+ )
366
+ page.locator(f"{PAGINATION}:visible").first.wait_for(timeout=STEP_TIMEOUT_MS)
367
+ _settle(page)
368
+
369
+ def _described(self, page: Page, raw: dict[str, Any]) -> Field | None:
370
+ """The field a raw description is, None for one atsfill leaves out."""
371
+ if raw["kind"] == "tiles":
372
+ if raw["required"]:
373
+ raise Unsupported(f"form_shape: {raw['label']!r} needs entries added")
374
+ return None
375
+ if raw["kind"] == "email":
376
+ self._kinds[EMAIL] = "email"
377
+ return None
378
+ if raw["kind"] == "select":
379
+ options = self._options(page, raw["id"])
380
+ raw["kind"] = "select" if options else "text" if raw["search"] else "typeahead"
381
+ raw["options"] = options or None
382
+ if raw["kind"] == "date":
383
+ self._months = self._date_options(page, raw["id"])
384
+ return _field(raw)
385
+
386
+ def _row(self, page: Page, field_id: str) -> Locator:
387
+ """The innermost row holding the field."""
388
+ selector = page.evaluate(_ROW, field_id)
389
+ if selector is None:
390
+ raise Unsupported(f"form_shape: {field_id} left the form while it was filled")
391
+ return page.locator(".input-row").filter(has=page.locator(selector)).last
392
+
393
+ def _options(self, page: Page, field_id: str) -> list[str]:
394
+ """A dropdown's options, from its list once opened; none when it lists only matches
395
+ for what is typed."""
396
+ box = self._row(page, field_id).locator("input[role=combobox]")
397
+ return _open_list(page, box)
398
+
399
+ def _date_options(self, page: Page, field_id: str) -> list[str]:
400
+ return _open_list(page, self._row(page, field_id).locator("input[role=combobox]").first)
401
+
402
+ def _date_value(self, parts: list[str]) -> str | None:
403
+ month, day, year = parts
404
+ if not (month in self._months and day.isdigit() and year.isdigit()):
405
+ return None
406
+ return date(int(year), self._months.index(month) + 1, int(day)).isoformat()
407
+
408
+ def _fill(self, page: Page, field_id: str, answer: Answer) -> None:
409
+ field, kind = self._fields[field_id], self._kinds[field_id]
410
+ if kind == "check":
411
+ box = page.locator(f"[id='{field_id}-checkbox'], [id='{field_id}-checkbox-label']")
412
+ host = box.first.locator("xpath=ancestor-or-self::*[.//input][1]")
413
+ checked = host.locator("input[type=checkbox]").first.is_checked()
414
+ if checked != (answer == "Yes"):
415
+ _click(page, host.locator(".apply-flow-input-checkbox__button").first)
416
+ return
417
+ if kind == "file":
418
+ assert isinstance(answer, str)
419
+ self._file_input(page, field_id).set_input_files(answer)
420
+ return
421
+ row = self._row(page, field_id)
422
+ match kind, answer:
423
+ case "text", str():
424
+ box = row.locator("input, textarea").first
425
+ box.fill(answer)
426
+ if box.get_attribute("role") == "combobox": # an address search, left unpicked
427
+ box.press("Escape")
428
+ box.blur()
429
+ case "pills" | "multipills", _:
430
+ for value in [answer] if isinstance(answer, str) else answer:
431
+ pill = row.locator(".cx-select-pill-section").filter(
432
+ has=page.locator(".cx-select-pill-name", has_text=_exact(value))
433
+ )
434
+ if "--selected" not in (pill.first.get_attribute("class") or ""):
435
+ _click(page, pill.first)
436
+ case "radios" | "checks", _:
437
+ for value in [answer] if isinstance(answer, str) else answer:
438
+ label = row.locator("label").filter(has_text=_exact(value)).first
439
+ target = page.locator(f"[id='{label.get_attribute('for')}']")
440
+ if not target.is_checked():
441
+ _click(page, label)
442
+ case "select", str():
443
+ _choose(page, row.locator("input[role=combobox]"), answer, exact=True)
444
+ self._picked[field_id] = answer
445
+ case "typeahead", str():
446
+ _choose(page, row.locator("input[role=combobox]"), answer, field=field)
447
+ case "phone", str():
448
+ self._phone(page, row, answer)
449
+ case "date", str():
450
+ year, month, day = answer.split("-")
451
+ boxes = row.locator("input[role=combobox]")
452
+ _choose(page, boxes.nth(0), self._months[int(month) - 1], exact=True)
453
+ _choose(page, boxes.nth(1), str(int(day)), exact=True)
454
+ _choose(page, boxes.nth(2), year, exact=True)
455
+ case _:
456
+ raise Unsupported(f"form_shape: cannot fill {field_id}, a {kind}, with {answer!r}")
457
+
458
+ def _phone(self, page: Page, row: Locator, answer: str) -> None:
459
+ """Picks the country code the number starts with, the longest that fits, then types
460
+ the rest of the number."""
461
+ digits = re.sub(r"\D", "", answer)
462
+ code = row.locator("input[role=combobox]")
463
+ codes = sorted(
464
+ {m[1] for o in _open_list(page, code) if (m := re.search(r"\+(\d+)", o))},
465
+ key=len,
466
+ reverse=True,
467
+ )
468
+ prefix = next((c for c in codes if digits.startswith(c)), None)
469
+ if prefix is None:
470
+ raise Unsupported(f"form_shape: no country code for {answer!r}")
471
+ _choose(page, code, f"+{prefix}", code=True)
472
+ number = row.locator("input[type=tel]")
473
+ number.fill(digits[len(prefix) :])
474
+ number.blur()
475
+
476
+ def _file_input(self, page: Page, field_id: str) -> Locator:
477
+ return page.locator(f"{_upload(field_id)} input[type=file]").first
478
+
479
+ def _wait_for_uploads(self, page: Page) -> None:
480
+ for field_id, name in self._uploads.items():
481
+ selector = f"{_upload(field_id)} :is({ATTACHED})"
482
+ done = page.locator(selector, has_text=name)
483
+ try:
484
+ done.first.wait_for(timeout=UPLOAD_TIMEOUT_MS)
485
+ except browser.Timeout:
486
+ return # the read-back shows what was kept
487
+
488
+
489
+ def _click(page: Page, target: Locator) -> None:
490
+ """Clicks, once more after what came over the target: the app's loading spinner, the
491
+ cookie banner (declined), or a header fixed over the top of the page."""
492
+ try:
493
+ browser.click(target)
494
+ except browser.Covered as e:
495
+ if "cookie-consent" in str(e):
496
+ buttons = page.locator(COOKIES)
497
+ decline = buttons.filter(has_text=re.compile("decline|reject|necessary", re.I))
498
+ browser.click((decline if decline.count() else buttons).first)
499
+ elif "loading" in str(e):
500
+ _settle(page)
501
+ else: # a header fixed over the top of the page
502
+ target.evaluate("e => e.scrollIntoView({block: 'center'})")
503
+ browser.click(target)
504
+
505
+
506
+ def _geography(described: list[tuple[dict, Field]]) -> list[tuple[dict, Field]]:
507
+ """Leaves one field of the address's geography (state, city, postal code), the most
508
+ specific: its items name the levels above (`0200, Acton, ACT`), and picking one fills
509
+ them. It is required when any level is."""
510
+ levels = [(r, f) for r, f in described if r.get("geo") and f.id != COUNTRY]
511
+ if len(levels) < 2 or not any(f.options for _, f in levels):
512
+ return described
513
+ parts = lambda f: max(len(o.split(",")) for o in f.options or [""]) # noqa: E731
514
+ kept = max(levels, key=lambda rf: parts(rf[1]))
515
+ required = any(f.required for _, f in levels)
516
+ return [
517
+ (r, replace(f, required=required) if (r, f) == kept else f)
518
+ for r, f in described
519
+ if (r, f) == kept or (r, f) not in levels
520
+ ]
521
+
522
+
523
+ def _page_numbers(page: Page) -> list[str]:
524
+ """The page numbers of a form over several pages, none for a single page."""
525
+ return [_text(t) for t in page.locator(PAGE_NUMBERS).all_inner_texts() if _text(t).isdigit()]
526
+
527
+
528
+ def _go_to(page: Page, number: int) -> None:
529
+ """Opens a page of the form by its number, clicked once more when the first click is lost
530
+ while the page settles."""
531
+ if not _page_numbers(page) or re.search(rf"/apply/section/{number}\b", page.url):
532
+ return
533
+ for _ in range(2):
534
+ _click(page, page.locator(PAGE_NUMBERS).filter(has_text=_exact(str(number))).first)
535
+ with suppress(browser.Timeout):
536
+ page.wait_for_url(
537
+ re.compile(rf"/apply/section/{number}\b"),
538
+ timeout=STEP_TIMEOUT_MS // 2,
539
+ wait_until="commit",
540
+ )
541
+ _settle(page)
542
+ return
543
+ raise Unsupported(f"form_shape: page {number} of the form did not open")
544
+
545
+
546
+ def _upload(field_id: str) -> str:
547
+ """The element holding a file field's upload button."""
548
+ hosts = {"resume": "resume-upload-button", "coverLetter": "cover-letter-upload-button"}
549
+ return hosts.get(field_id) or f"apply-flow-block[data-scroll-spy-id='{field_id[6:]}']"
550
+
551
+
552
+ def _text(value: str) -> str:
553
+ return re.sub(r"\s+", " ", value).replace("*", "").strip()
554
+
555
+
556
+ def _exact(value: str) -> re.Pattern:
557
+ return re.compile(rf"^\s*{re.escape(value)}\s*$")
558
+
559
+
560
+ def _settle(page: Page) -> None:
561
+ """Waits for the dropdowns to load, and the address block for its country's format."""
562
+ with suppress(browser.Timeout):
563
+ page.wait_for_function(_SETTLED, arg=LOADING, timeout=STEP_TIMEOUT_MS)
564
+ page.wait_for_timeout(SETTLE_MS)
565
+
566
+
567
+ def _open_list(page: Page, box: Locator) -> list[str]:
568
+ """The items a dropdown lists once opened, then closed again."""
569
+ _click(page, box)
570
+ cells = page.locator(f"[id='{box.get_attribute('aria-controls')}'] [role=gridcell]")
571
+ try:
572
+ cells.first.wait_for(timeout=LIST_TIMEOUT_MS)
573
+ options = [_text(t) for t in cells.all_inner_texts()]
574
+ except browser.Timeout:
575
+ options = []
576
+ box.press("Escape")
577
+ return options
578
+
579
+
580
+ def _choose(
581
+ page: Page,
582
+ box: Locator,
583
+ answer: str,
584
+ *,
585
+ exact: bool = False,
586
+ code: bool = False,
587
+ field: Field | None = None,
588
+ ) -> None:
589
+ """Types into a dropdown, which filters its list, and picks the item that is the answer
590
+ (or holds it as a phone code, or matches it as a suggestion). A list whose search does not match
591
+ its items' text as typed is looked through whole."""
592
+ for typed in (answer, ""):
593
+ _click(page, box)
594
+ box.fill("")
595
+ if typed:
596
+ box.press_sequentially(typed, delay=20)
597
+ cells = page.locator(f"[id='{box.get_attribute('aria-controls')}'] [role=gridcell]")
598
+ with suppress(browser.Timeout):
599
+ cells.first.wait_for(timeout=LIST_TIMEOUT_MS * 2)
600
+ for i in range(cells.count()):
601
+ item = _text(cells.nth(i).inner_text())
602
+ if (
603
+ (exact and item == answer)
604
+ or (code and re.search(rf"{re.escape(answer)}(\D|$)", item))
605
+ or (field is not None and matching.matches(field, answer, item))
606
+ ):
607
+ cells.nth(i).scroll_into_view_if_needed()
608
+ _click(page, cells.nth(i))
609
+ return
610
+ box.press("Escape")
611
+ if field is not None:
612
+ break # a search lists nothing untyped
613
+ raise Unsupported(f"form_shape: no item for {answer!r} in a dropdown")
614
+
615
+
616
+ def _field(raw: dict[str, Any]) -> Field:
617
+ kind = FieldType.TYPEAHEAD if raw["kind"] == "typeahead" else KINDS[raw["kind"]]
618
+ options = CHECKBOX if raw["kind"] == "check" else raw.get("options")
619
+ return Field(
620
+ raw["id"],
621
+ raw["label"],
622
+ kind,
623
+ raw["required"],
624
+ options,
625
+ raw.get("maxLength"),
626
+ raw.get("accept"),
627
+ prefilled=bool(raw.get("value")),
628
+ )
@@ -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()
@@ -70,14 +70,18 @@ kind = "ats"
70
70
  share = 0.0833
71
71
  account = "none"
72
72
  hosting = ["hosted"]
73
+ adapter = "oracle"
73
74
  mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
75
+ dry_run_verified = { date = 2026-10-20, version = "0.0.3", by = "gate", commit = "1b67ce5" }
74
76
 
75
77
  [smartrecruiters]
76
78
  kind = "ats"
77
79
  share = 0.0441
78
80
  account = "none"
79
81
  hosting = ["hosted"]
82
+ adapter = "smartrecruiters"
80
83
  mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
84
+ dry_run_verified = { date = 2026-10-20, version = "0.0.3", by = "gate", commit = "d7eb8e7" }
81
85
 
82
86
  [successfactors]
83
87
  kind = "ats"
@@ -1,7 +0,0 @@
1
- from atsfill.adapters.ashby import Ashby
2
- from atsfill.adapters.greenhouse import Greenhouse
3
- from atsfill.adapters.teamtailor import Teamtailor
4
- from atsfill.adapters.wttj import WelcomeToTheJungle
5
- from atsfill.core.adapter import Adapter
6
-
7
- ADAPTERS: list[Adapter] = [Ashby(), Teamtailor(), Greenhouse(), WelcomeToTheJungle()]
File without changes