atsfill 0.0.4.dev20__tar.gz → 0.0.4.dev22__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev22}/PKG-INFO +2 -2
- {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev22}/README.md +1 -1
- {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev22}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev22}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev22}/src/atsfill/adapters/__init__.py +2 -0
- atsfill-0.0.4.dev22/src/atsfill/adapters/oracle.py +628 -0
- {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev22}/src/atsfill/registry.toml +2 -0
- {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev22}/LICENSE +0 -0
- {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev22}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev22}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev22}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev22}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev22}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev22}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev22}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev22}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev22}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev22}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev22}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev22}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev22}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev22}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev22}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev22}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev22}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev20 → atsfill-0.0.4.dev22}/src/atsfill/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: atsfill
|
|
3
|
-
Version: 0.0.4.
|
|
3
|
+
Version: 0.0.4.dev22
|
|
4
4
|
Summary: Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle.
|
|
5
5
|
Keywords: job-applications,ats,ashby,greenhouse,teamtailor,ai-agents,cli
|
|
6
6
|
Author: shbernal
|
|
@@ -53,7 +53,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
53
53
|
## Supported hiring systems
|
|
54
54
|
|
|
55
55
|
<!-- badges -->
|
|
56
|
-
    
|
|
56
|
+
     
|
|
57
57
|
<!-- /badges -->
|
|
58
58
|
|
|
59
59
|
| System | Account needed | `read` without a browser |
|
|
@@ -29,7 +29,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
29
29
|
## Supported hiring systems
|
|
30
30
|
|
|
31
31
|
<!-- badges -->
|
|
32
|
-
    
|
|
32
|
+
     
|
|
33
33
|
<!-- /badges -->
|
|
34
34
|
|
|
35
35
|
| System | Account needed | `read` without a browser |
|
|
@@ -1,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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|