atsfill 0.0.4.dev20__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 (26) hide show
  1. {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev21}/PKG-INFO +2 -2
  2. {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev21}/README.md +1 -1
  3. {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev21}/pyproject.toml +1 -1
  4. {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev21}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev21}/src/atsfill/adapters/__init__.py +2 -0
  6. atsfill-0.0.4.dev21/src/atsfill/adapters/oracle.py +628 -0
  7. {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev21}/src/atsfill/registry.toml +2 -0
  8. {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev21}/LICENSE +0 -0
  9. {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev21}/src/atsfill/__init__.py +0 -0
  10. {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev21}/src/atsfill/adapters/ashby.py +0 -0
  11. {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev21}/src/atsfill/adapters/greenhouse.py +0 -0
  12. {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev21}/src/atsfill/adapters/smartrecruiters.py +0 -0
  13. {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev21}/src/atsfill/adapters/teamtailor.py +0 -0
  14. {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev21}/src/atsfill/adapters/wttj.py +0 -0
  15. {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev21}/src/atsfill/cli.py +0 -0
  16. {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev21}/src/atsfill/core/__init__.py +0 -0
  17. {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev21}/src/atsfill/core/accounts.py +0 -0
  18. {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev21}/src/atsfill/core/adapter.py +0 -0
  19. {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev21}/src/atsfill/core/attempts.py +0 -0
  20. {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev21}/src/atsfill/core/browser.py +0 -0
  21. {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev21}/src/atsfill/core/commands.py +0 -0
  22. {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev21}/src/atsfill/core/matching.py +0 -0
  23. {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev21}/src/atsfill/core/paths.py +0 -0
  24. {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev21}/src/atsfill/core/registry.py +0 -0
  25. {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev21}/src/atsfill/core/results.py +0 -0
  26. {atsfill-0.0.4.dev20 → 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.dev20
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
- ![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)
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
- ![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)
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.dev20"
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.dev20"
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,5 +1,6 @@
1
1
  from atsfill.adapters.ashby import Ashby
2
2
  from atsfill.adapters.greenhouse import Greenhouse
3
+ from atsfill.adapters.oracle import Oracle
3
4
  from atsfill.adapters.smartrecruiters import SmartRecruiters
4
5
  from atsfill.adapters.teamtailor import Teamtailor
5
6
  from atsfill.adapters.wttj import WelcomeToTheJungle
@@ -11,4 +12,5 @@ ADAPTERS: list[Adapter] = [
11
12
  Greenhouse(),
12
13
  WelcomeToTheJungle(),
13
14
  SmartRecruiters(),
15
+ Oracle(),
14
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
+ )
@@ -70,7 +70,9 @@ 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"
File without changes