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.
- {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/PKG-INFO +2 -2
- {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/README.md +1 -1
- {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/pyproject.toml.orig +1 -1
- atsfill-0.0.4.dev21/src/atsfill/adapters/__init__.py +16 -0
- atsfill-0.0.4.dev21/src/atsfill/adapters/oracle.py +628 -0
- atsfill-0.0.4.dev21/src/atsfill/adapters/smartrecruiters.py +408 -0
- {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/src/atsfill/registry.toml +4 -0
- atsfill-0.0.4.dev19/src/atsfill/adapters/__init__.py +0 -7
- {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/LICENSE +0 -0
- {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev19 → atsfill-0.0.4.dev21}/src/atsfill/core/results.py +0 -0
- {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.
|
|
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
|
-
   
|
|
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 |
|
|
@@ -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
|
|
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
|