atsfill 0.0.4.dev107__tar.gz → 0.0.4.dev111__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.dev107 → atsfill-0.0.4.dev111}/PKG-INFO +2 -2
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/README.md +1 -1
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/__init__.py +2 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/personio.py +21 -76
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/pinpoint.py +24 -78
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/teamtailor.py +3 -2
- atsfill-0.0.4.dev111/src/atsfill/adapters/ukg.py +485 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/registry.toml +2 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/LICENSE +0 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev111}/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.dev111
|
|
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
|
|
@@ -57,7 +57,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
57
57
|
## Supported hiring systems
|
|
58
58
|
|
|
59
59
|
<!-- badges -->
|
|
60
|
-
                        
|
|
60
|
+
                         
|
|
61
61
|
<!-- /badges -->
|
|
62
62
|
|
|
63
63
|
| 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 |
|
|
@@ -19,6 +19,7 @@ from atsfill.adapters.recruitee import Recruitee
|
|
|
19
19
|
from atsfill.adapters.rippling import Rippling
|
|
20
20
|
from atsfill.adapters.smartrecruiters import SmartRecruiters
|
|
21
21
|
from atsfill.adapters.teamtailor import Teamtailor
|
|
22
|
+
from atsfill.adapters.ukg import UKG
|
|
22
23
|
from atsfill.adapters.workable import Workable
|
|
23
24
|
from atsfill.adapters.workday import Workday
|
|
24
25
|
from atsfill.adapters.wttj import WelcomeToTheJungle
|
|
@@ -53,6 +54,7 @@ def adapters() -> list[Adapter]:
|
|
|
53
54
|
HiringThing(),
|
|
54
55
|
Eightfold(),
|
|
55
56
|
Workday(),
|
|
57
|
+
UKG(),
|
|
56
58
|
Phenom(), # last: it claims a URL by its path alone
|
|
57
59
|
]
|
|
58
60
|
|
|
@@ -9,10 +9,9 @@ list the fields from it with one parser. A document is uploaded to Personio once
|
|
|
9
9
|
import re
|
|
10
10
|
import urllib.request
|
|
11
11
|
from contextlib import suppress
|
|
12
|
-
from dataclasses import dataclass, field
|
|
13
|
-
from html.parser import HTMLParser
|
|
14
12
|
from urllib.parse import urlsplit
|
|
15
13
|
|
|
14
|
+
from atsfill.core import html
|
|
16
15
|
from atsfill.core.adapter import Blocked, Gap, Unsupported
|
|
17
16
|
from atsfill.core.browser import HTTP_TIMEOUT_S, Locator, Page, Timeout
|
|
18
17
|
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
@@ -35,7 +34,6 @@ KINDS = {
|
|
|
35
34
|
"select": FieldType.CHOICE,
|
|
36
35
|
"file": FieldType.FILE,
|
|
37
36
|
}
|
|
38
|
-
VOID = {"input", "br", "img", "hr", "meta", "link", "source", "wbr", "col", "area"}
|
|
39
37
|
|
|
40
38
|
# What each control holds: a value, a select's option value, a document's uploaded names.
|
|
41
39
|
_READ_BACK = """(form, ids) => {
|
|
@@ -78,10 +76,10 @@ class Personio:
|
|
|
78
76
|
request = urllib.request.Request(_apply(url), headers={"User-Agent": "atsfill"})
|
|
79
77
|
try:
|
|
80
78
|
with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
|
|
81
|
-
|
|
79
|
+
page = html.parse(response.read().decode())
|
|
82
80
|
except OSError, ValueError:
|
|
83
81
|
return None
|
|
84
|
-
form = next((n for n in
|
|
82
|
+
form = next((n for n in page.walk() if n.tag == "form"), None)
|
|
85
83
|
return [f for f, _ in _form(form)] if form else None
|
|
86
84
|
|
|
87
85
|
def browser_read(self, page: Page) -> list[Field]:
|
|
@@ -91,7 +89,7 @@ class Personio:
|
|
|
91
89
|
page.locator(SUBMIT).wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
|
|
92
90
|
except Timeout:
|
|
93
91
|
raise Unsupported("posting_gone: no application form on the page") from None
|
|
94
|
-
listed = _form(
|
|
92
|
+
listed = _form(html.parse(page.locator(FORM).evaluate("f => f.outerHTML")))
|
|
95
93
|
self._options = {f.id: o for f, o in listed if o and not f.unfillable}
|
|
96
94
|
self._fields = [f for f, _ in listed if not f.unfillable]
|
|
97
95
|
return [f for f, _ in listed]
|
|
@@ -165,73 +163,7 @@ def _apply(url: str) -> str:
|
|
|
165
163
|
return f"https://{parts.hostname}/job/{job[1]}/apply{query}"
|
|
166
164
|
|
|
167
165
|
|
|
168
|
-
|
|
169
|
-
class _Node:
|
|
170
|
-
tag: str
|
|
171
|
-
attrs: dict[str, str]
|
|
172
|
-
parent: _Node | None = None
|
|
173
|
-
children: list[_Node | str] = field(default_factory=list)
|
|
174
|
-
|
|
175
|
-
def walk(self) -> list[_Node]:
|
|
176
|
-
out = []
|
|
177
|
-
for child in self.children:
|
|
178
|
-
if isinstance(child, _Node):
|
|
179
|
-
out += [child, *child.walk()]
|
|
180
|
-
return out
|
|
181
|
-
|
|
182
|
-
def text(self) -> str:
|
|
183
|
-
return "".join(c if isinstance(c, str) else c.text() for c in self.children)
|
|
184
|
-
|
|
185
|
-
def label(self) -> str:
|
|
186
|
-
"""The text, without the spans that mark it required or optional."""
|
|
187
|
-
own = (c for c in self.children if isinstance(c, str) or c.tag != "span")
|
|
188
|
-
return " ".join("".join(c if isinstance(c, str) else c.text() for c in own).split())
|
|
189
|
-
|
|
190
|
-
def starred(self) -> bool:
|
|
191
|
-
return any(
|
|
192
|
-
isinstance(c, _Node) and c.tag == "span" and c.text().strip() == "*"
|
|
193
|
-
for c in self.children
|
|
194
|
-
)
|
|
195
|
-
|
|
196
|
-
def ancestor(self, role: str) -> _Node | None:
|
|
197
|
-
node = self.parent
|
|
198
|
-
while node is not None and node.attrs.get("role") != role:
|
|
199
|
-
node = node.parent
|
|
200
|
-
return node
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
class _Tree(HTMLParser):
|
|
204
|
-
def __init__(self) -> None:
|
|
205
|
-
super().__init__()
|
|
206
|
-
self.root = self.open = _Node("", {})
|
|
207
|
-
|
|
208
|
-
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
209
|
-
node = _Node(tag, {k: v or "" for k, v in attrs}, self.open)
|
|
210
|
-
self.open.children.append(node)
|
|
211
|
-
if tag not in VOID:
|
|
212
|
-
self.open = node
|
|
213
|
-
|
|
214
|
-
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
215
|
-
self.open.children.append(_Node(tag, {k: v or "" for k, v in attrs}, self.open))
|
|
216
|
-
|
|
217
|
-
def handle_endtag(self, tag: str) -> None:
|
|
218
|
-
node: _Node | None = self.open
|
|
219
|
-
while node is not None and node.tag != tag:
|
|
220
|
-
node = node.parent
|
|
221
|
-
if node is not None and node.parent is not None:
|
|
222
|
-
self.open = node.parent
|
|
223
|
-
|
|
224
|
-
def handle_data(self, data: str) -> None:
|
|
225
|
-
self.open.children.append(data)
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
def _parse(html: str) -> _Node:
|
|
229
|
-
tree = _Tree()
|
|
230
|
-
tree.feed(html)
|
|
231
|
-
return tree.root
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
def _form(form: _Node) -> list[tuple[Field, dict[str, str] | None]]:
|
|
166
|
+
def _form(form: html.Node) -> list[tuple[Field, dict[str, str] | None]]:
|
|
235
167
|
"""Each named control of the form, with a choice's option texts by value."""
|
|
236
168
|
nodes = form.walk()
|
|
237
169
|
by_id = {n.attrs["id"]: n for n in nodes if "id" in n.attrs}
|
|
@@ -244,11 +176,11 @@ def _form(form: _Node) -> list[tuple[Field, dict[str, str] | None]]:
|
|
|
244
176
|
kind = node.attrs.get("type", "text") if node.tag == "input" else node.tag
|
|
245
177
|
if kind == "hidden":
|
|
246
178
|
continue
|
|
247
|
-
group = node.
|
|
179
|
+
group = node.closest(lambda n: n.attrs.get("role") == "group")
|
|
248
180
|
group_label = by_id.get(group.attrs.get("aria-labelledby", "")) if group else None
|
|
249
181
|
own = labels.get(node.attrs.get("id", ""))
|
|
250
|
-
label = (own
|
|
251
|
-
required = any(n is not None and n
|
|
182
|
+
label = (_label(own) if own else "") or (_label(group_label) if group_label else "") or name
|
|
183
|
+
required = any(n is not None and _starred(n) for n in (own, group_label))
|
|
252
184
|
if kind not in KINDS or "multiple" in node.attrs and kind == "select":
|
|
253
185
|
reason = f"unhandled_rendering: {name} is a {kind}"
|
|
254
186
|
out.append((Field(name, label, FieldType.TEXT, required, unfillable=reason), None))
|
|
@@ -282,6 +214,19 @@ def _form(form: _Node) -> list[tuple[Field, dict[str, str] | None]]:
|
|
|
282
214
|
return out
|
|
283
215
|
|
|
284
216
|
|
|
217
|
+
def _label(node: html.Node) -> str:
|
|
218
|
+
"""A label's text, without the spans that mark it required or optional."""
|
|
219
|
+
own = (c for c in node.children if isinstance(c, str) or c.tag != "span")
|
|
220
|
+
return " ".join("".join(c if isinstance(c, str) else c.text() for c in own).split())
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _starred(node: html.Node) -> bool:
|
|
224
|
+
return any(
|
|
225
|
+
isinstance(c, html.Node) and c.tag == "span" and c.text().strip() == "*"
|
|
226
|
+
for c in node.children
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
285
230
|
def _attach(control: Locator, path: str) -> None:
|
|
286
231
|
"""Attaches a document and waits for Personio's upload to show its name in the category."""
|
|
287
232
|
name = path.rsplit("/", 1)[-1]
|
|
@@ -11,12 +11,12 @@ import json
|
|
|
11
11
|
import re
|
|
12
12
|
import urllib.request
|
|
13
13
|
from contextlib import suppress
|
|
14
|
-
from dataclasses import dataclass,
|
|
14
|
+
from dataclasses import dataclass, replace
|
|
15
15
|
from datetime import date
|
|
16
|
-
from html.parser import HTMLParser
|
|
17
16
|
from typing import Any
|
|
18
17
|
from urllib.parse import urlsplit
|
|
19
18
|
|
|
19
|
+
from atsfill.core import html
|
|
20
20
|
from atsfill.core.adapter import Gap, NotTaken, Unsupported, known_gap
|
|
21
21
|
from atsfill.core.browser import HTTP_TIMEOUT_S, Locator, Page, Timeout
|
|
22
22
|
from atsfill.core.results import Answer, AnyOf, Condition, Field, FieldType, Is, SideEffect
|
|
@@ -43,7 +43,6 @@ JOB_PREFERENCES = "job_preferences"
|
|
|
43
43
|
TEXT_QUESTIONS = {"Shorttext", "Longtext", "Url", "Numberinput"} # Url is a `url` field
|
|
44
44
|
FILE_QUESTIONS = {"Document", "Multipledocuments"}
|
|
45
45
|
NOT_FIELDS = {"New", "Chatbot", "Stagescarousel"} # components without a field, by last name
|
|
46
|
-
VOID = {"input", "br", "img", "hr", "meta", "link", "source", "wbr", "col", "area"}
|
|
47
46
|
|
|
48
47
|
# What each field holds, by how it renders: a control's value, a react-select's shown value(s),
|
|
49
48
|
# a radio's label, a date's three selects, a document's uploaded names.
|
|
@@ -131,12 +130,10 @@ class Pinpoint:
|
|
|
131
130
|
request = urllib.request.Request(_apply(url), headers={"User-Agent": "atsfill"})
|
|
132
131
|
try:
|
|
133
132
|
with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
|
|
134
|
-
|
|
133
|
+
page = html.parse(response.read().decode())
|
|
135
134
|
except OSError, ValueError:
|
|
136
135
|
return None
|
|
137
|
-
form = next(
|
|
138
|
-
(n for n in _parse(html).walk() if n.attrs.get("id") == "application-form"), None
|
|
139
|
-
)
|
|
136
|
+
form = next((n for n in page.walk() if n.attrs.get("id") == "application-form"), None)
|
|
140
137
|
return [f for f, _ in _form(form, self)] if form else None
|
|
141
138
|
|
|
142
139
|
def browser_read(self, page: Page) -> list[Field]:
|
|
@@ -147,7 +144,7 @@ class Pinpoint:
|
|
|
147
144
|
except Timeout:
|
|
148
145
|
raise Unsupported("posting_gone: no application form on the page") from None
|
|
149
146
|
_hydrated(page)
|
|
150
|
-
listed = _form(
|
|
147
|
+
listed = _form(html.parse(page.locator(FORM).evaluate("f => f.outerHTML")), self)
|
|
151
148
|
self._specs = {f.id: s for f, s in listed if not f.unfillable}
|
|
152
149
|
if "country" in self._specs: # the visitor's, guessed by the page
|
|
153
150
|
held = self.read_back(page).get("country")
|
|
@@ -200,7 +197,7 @@ class Pinpoint:
|
|
|
200
197
|
def shown_fields(self, page: Page) -> list[str]:
|
|
201
198
|
"""The fields of the filled page's form whose control shows: a question component
|
|
202
199
|
renders none while its parent's answer keeps it hidden, an address line one layout's."""
|
|
203
|
-
listed = _form(
|
|
200
|
+
listed = _form(html.parse(page.locator(FORM).evaluate("f => f.outerHTML")), self)
|
|
204
201
|
specs = [{"id": f.id, "kind": s.kind, "root": s.root} for f, s in listed]
|
|
205
202
|
return page.locator(FORM).evaluate(_SHOWN, specs)
|
|
206
203
|
|
|
@@ -297,66 +294,6 @@ def _attach(root: Locator, path: str) -> None:
|
|
|
297
294
|
root.locator(".bp3-tag a.link", has_text=name).first.wait_for(timeout=UPLOAD_TIMEOUT_MS)
|
|
298
295
|
|
|
299
296
|
|
|
300
|
-
@dataclass
|
|
301
|
-
class _Node:
|
|
302
|
-
tag: str
|
|
303
|
-
attrs: dict[str, str]
|
|
304
|
-
parent: _Node | None = None
|
|
305
|
-
children: list[_Node | str] = field(default_factory=list)
|
|
306
|
-
|
|
307
|
-
def walk(self) -> list[_Node]:
|
|
308
|
-
out = []
|
|
309
|
-
for child in self.children:
|
|
310
|
-
if isinstance(child, _Node):
|
|
311
|
-
out += [child, *child.walk()]
|
|
312
|
-
return out
|
|
313
|
-
|
|
314
|
-
def text(self) -> str:
|
|
315
|
-
return " ".join(
|
|
316
|
-
"".join(c if isinstance(c, str) else c.text() for c in self.children).split()
|
|
317
|
-
)
|
|
318
|
-
|
|
319
|
-
def classes(self) -> list[str]:
|
|
320
|
-
return self.attrs.get("class", "").split()
|
|
321
|
-
|
|
322
|
-
def in_component(self) -> bool:
|
|
323
|
-
node = self.parent
|
|
324
|
-
while node is not None and "-react-component-" not in node.attrs.get("id", ""):
|
|
325
|
-
node = node.parent
|
|
326
|
-
return node is not None
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
class _Tree(HTMLParser):
|
|
330
|
-
def __init__(self) -> None:
|
|
331
|
-
super().__init__(convert_charrefs=True)
|
|
332
|
-
self.root = self.open = _Node("", {})
|
|
333
|
-
|
|
334
|
-
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
335
|
-
node = _Node(tag, {k: v or "" for k, v in attrs}, self.open)
|
|
336
|
-
self.open.children.append(node)
|
|
337
|
-
if tag not in VOID:
|
|
338
|
-
self.open = node
|
|
339
|
-
|
|
340
|
-
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
341
|
-
self.open.children.append(_Node(tag, {k: v or "" for k, v in attrs}, self.open))
|
|
342
|
-
|
|
343
|
-
def handle_endtag(self, tag: str) -> None:
|
|
344
|
-
node: _Node | None = self.open
|
|
345
|
-
while node is not None and node.tag != tag:
|
|
346
|
-
node = node.parent
|
|
347
|
-
if node is not None and node.parent is not None:
|
|
348
|
-
self.open = node.parent
|
|
349
|
-
|
|
350
|
-
def handle_data(self, data: str) -> None:
|
|
351
|
-
self.open.children.append(data)
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
def _parse(html: str) -> _Node:
|
|
355
|
-
tree = _Tree()
|
|
356
|
-
tree.feed(html)
|
|
357
|
-
return tree.root
|
|
358
|
-
|
|
359
|
-
|
|
360
297
|
def _key(name: str) -> str:
|
|
361
298
|
"""A control's attribute: `first_name` for `application_form[application][first_name]`."""
|
|
362
299
|
return re.findall(r"\w+", re.sub(r"\(\di\)", "", name))[-1]
|
|
@@ -367,27 +304,36 @@ def _text(label: object) -> str:
|
|
|
367
304
|
return " ".join(str(label).split())
|
|
368
305
|
|
|
369
306
|
|
|
307
|
+
def _in_component(node: html.Node) -> bool:
|
|
308
|
+
"""Whether a control is one a React component renders, read from its props instead."""
|
|
309
|
+
return node.parent is not None and bool(
|
|
310
|
+
node.parent.closest(lambda n: "-react-component-" in n.attrs.get("id", ""))
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
370
314
|
def _accept(extensions: list[str] | None) -> list[str] | None:
|
|
371
315
|
return [f".{e}" for e in extensions] if extensions else None
|
|
372
316
|
|
|
373
317
|
|
|
374
|
-
def _form(form:
|
|
318
|
+
def _form(form: html.Node, adapter: Pinpoint) -> list[tuple[Field, _Spec]]:
|
|
375
319
|
"""Each field of the form, in its order, with where its control is."""
|
|
376
320
|
nodes = form.walk()
|
|
377
|
-
labels = {
|
|
321
|
+
labels = {
|
|
322
|
+
n.attrs["for"]: _text(n.text()) for n in nodes if n.tag == "label" and "for" in n.attrs
|
|
323
|
+
}
|
|
378
324
|
out: list[tuple[Field, _Spec]] = []
|
|
379
325
|
titles: dict[int, tuple[str, dict[str, str]]] = {} # a question's id, its values' labels
|
|
380
326
|
for node in nodes:
|
|
381
327
|
if node.tag == "script" and COMPONENT in node.classes():
|
|
382
|
-
props = json.loads(node.text() or "{}")
|
|
328
|
+
props = json.loads(_text(node.text()) or "{}")
|
|
383
329
|
name = node.attrs.get("data-component-name", "")
|
|
384
330
|
out += _component(name, props, node, labels, titles, adapter)
|
|
385
|
-
elif node.tag in ("input", "select", "textarea") and not node
|
|
331
|
+
elif node.tag in ("input", "select", "textarea") and not _in_component(node):
|
|
386
332
|
out += _plain(node, labels, out)
|
|
387
333
|
return out
|
|
388
334
|
|
|
389
335
|
|
|
390
|
-
def _plain(node:
|
|
336
|
+
def _plain(node: html.Node, labels: dict[str, str], listed: list) -> list[tuple[Field, _Spec]]:
|
|
391
337
|
"""A server-rendered control: a name, the email, the summary, a date of birth, the consent."""
|
|
392
338
|
name, kind = node.attrs.get("name", ""), node.attrs.get("type", node.tag)
|
|
393
339
|
if not name or kind in ("hidden", "submit", "button") or "g-recaptcha" in name:
|
|
@@ -417,7 +363,7 @@ def _plain(node: _Node, labels: dict[str, str], listed: list) -> list[tuple[Fiel
|
|
|
417
363
|
def _component(
|
|
418
364
|
name: str,
|
|
419
365
|
props: dict[str, Any],
|
|
420
|
-
script:
|
|
366
|
+
script: html.Node,
|
|
421
367
|
labels: dict[str, str],
|
|
422
368
|
titles: dict[int, tuple[str, dict[str, str]]],
|
|
423
369
|
adapter: Pinpoint,
|
|
@@ -536,17 +482,17 @@ def _shown_when(parent: tuple[str, dict[str, str]], conditions: list[dict]) -> C
|
|
|
536
482
|
return None if not held else held[0] if len(held) == 1 else AnyOf(held)
|
|
537
483
|
|
|
538
484
|
|
|
539
|
-
def _file_label(script:
|
|
485
|
+
def _file_label(script: html.Node, props: dict) -> tuple[str, bool]:
|
|
540
486
|
"""A document's label, from the `external-form__label` block before its component."""
|
|
541
487
|
parent = script.parent
|
|
542
|
-
siblings = [c for c in parent.children if isinstance(c,
|
|
488
|
+
siblings = [c for c in parent.children if isinstance(c, html.Node)] if parent else []
|
|
543
489
|
before = siblings[: siblings.index(script)] if script in siblings else []
|
|
544
490
|
block = next((n for n in reversed(before) if "external-form__label" in n.classes()), None)
|
|
545
491
|
if block is None:
|
|
546
492
|
return props.get("labelText") or _key(props["name"]), False
|
|
547
493
|
title = next((n for n in block.walk() if n.tag == "div"), block)
|
|
548
494
|
required = any("external-form__label--required" in n.classes() for n in [block, *block.walk()])
|
|
549
|
-
return title.text(), required
|
|
495
|
+
return _text(title.text()), required
|
|
550
496
|
|
|
551
497
|
|
|
552
498
|
def _address(props: dict, root: str | None) -> list[tuple[Field, _Spec]]:
|
|
@@ -238,8 +238,9 @@ class Teamtailor:
|
|
|
238
238
|
host.locator("input[type=file]").set_input_files(answer)
|
|
239
239
|
case FieldType.CHOICE, str() if host.locator("input[type=checkbox]").count():
|
|
240
240
|
box = host.locator("input[type=checkbox]")
|
|
241
|
-
|
|
242
|
-
|
|
241
|
+
label = page.locator(f"label[for='{box.get_attribute('id')}']")
|
|
242
|
+
if box.is_checked() != (answer == "Yes"): # an empty label has no box to click
|
|
243
|
+
browser.click(label if label.is_visible() else box)
|
|
243
244
|
case FieldType.CHOICE, str() if host.locator("input[type=range]").count():
|
|
244
245
|
host.locator("input[type=range]").fill(answer)
|
|
245
246
|
case FieldType.TYPEAHEAD, str():
|
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
"""UKG Pro Recruiting (`recruiting[2].ultipro.com`, `recruiting.ultipro.ca`,
|
|
2
|
+
`<employer>.rec.pro.ukg.net`: `/<tenant>/JobBoard/<board>/OpportunityDetail?opportunityId=…`),
|
|
3
|
+
as docs/adapters/ukg.md observes.
|
|
4
|
+
|
|
5
|
+
Applying needs an account, one per tenant: the account scope. The form is one page, a
|
|
6
|
+
Knockout view of the posting, read from the page; the read-back is the page too.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import time
|
|
11
|
+
from contextlib import suppress
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.parse import parse_qs, quote, urlsplit
|
|
15
|
+
|
|
16
|
+
from atsfill.core.adapter import Blocked, Gap, NotTaken, Refused, Unsupported
|
|
17
|
+
from atsfill.core.browser import Locator, Page, Request, Timeout
|
|
18
|
+
from atsfill.core.results import Answer, Field, FieldType, Is, OptionSet, SideEffect
|
|
19
|
+
|
|
20
|
+
HOST = re.compile(r"^(recruiting\d*\.ultipro\.(com|ca)|[a-z0-9-]+\.rec\.pro\.ukg\.net)$")
|
|
21
|
+
PATH = re.compile(
|
|
22
|
+
r"^/(?P<tenant>[A-Za-z0-9]+)/JobBoard/(?P<board>[0-9a-fA-F-]{36})/Opportunity(Detail|Apply)$"
|
|
23
|
+
)
|
|
24
|
+
SIGN_IN_HOST = re.compile(r"^signin-[a-z0-9]+\.ultipro\.com$")
|
|
25
|
+
SESSION_COOKIE = ".AspNetCore.Cookies"
|
|
26
|
+
FORM = "#OpportunityApply"
|
|
27
|
+
READY = f"{FORM} [data-automation=btn-submit]"
|
|
28
|
+
FORM_TIMEOUT_MS = 30_000
|
|
29
|
+
SIGN_IN_TIMEOUT_S = 30
|
|
30
|
+
SIGN_IN_POLL_MS = 500
|
|
31
|
+
STATES_TIMEOUT_MS = 10_000
|
|
32
|
+
UPLOAD_TIMEOUT_MS = 30_000
|
|
33
|
+
SUBMIT_TIMEOUT_MS = 90_000
|
|
34
|
+
SETTLE_MS = 500
|
|
35
|
+
YES_NO = ["Yes", "No"]
|
|
36
|
+
DATE_SHOWN = re.compile(r"(\d\d)/(\d\d)/(\d{4})")
|
|
37
|
+
COUNTRY, STATE = "Country", "State"
|
|
38
|
+
DAYS, DOCUMENTS = "availability_days", "documents"
|
|
39
|
+
PHONE_GAP = "sign_up_phone"
|
|
40
|
+
|
|
41
|
+
# Auth0's error codes on the sign-in and sign-up pages, by what atsfill raises
|
|
42
|
+
ERRORS = [
|
|
43
|
+
(re.compile(r"wrong-(credentials|email-credentials)|invalid-(password|email-password)"),
|
|
44
|
+
Refused, "wrong_password"),
|
|
45
|
+
(re.compile(r"user-exists|email-in-use|already"), Refused, "email_in_use"),
|
|
46
|
+
(re.compile(r"password"), Refused, "password_rule"),
|
|
47
|
+
(re.compile(r"captcha"), Blocked, "captcha"),
|
|
48
|
+
(re.compile(r"too-many|blocked|rate"), Blocked, "rate_limited"),
|
|
49
|
+
] # fmt: skip
|
|
50
|
+
|
|
51
|
+
# Each visible field of the form, in page order: a control's id (a radio group's name), its
|
|
52
|
+
# label, how it renders, whether it is required, its options and value.
|
|
53
|
+
_READ = """root => {
|
|
54
|
+
const clean = s => (s || "").replace(/\\s+/g, " ").trim();
|
|
55
|
+
const shown = el => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length) &&
|
|
56
|
+
!el.closest(".modal");
|
|
57
|
+
const labelOf = el => {
|
|
58
|
+
if (el.id) {
|
|
59
|
+
const l = root.querySelector(`label[for="${CSS.escape(el.id)}"]`);
|
|
60
|
+
if (l) return l;
|
|
61
|
+
}
|
|
62
|
+
const group = el.closest("[aria-labelledby]");
|
|
63
|
+
if (group) {
|
|
64
|
+
const l = document.getElementById(group.getAttribute("aria-labelledby").split(" ")[0]);
|
|
65
|
+
if (l) return l;
|
|
66
|
+
}
|
|
67
|
+
return el.closest(".form-group")?.querySelector("label");
|
|
68
|
+
};
|
|
69
|
+
const required = (el, label) => el.required || !!label?.classList.contains("required");
|
|
70
|
+
const out = [], seen = new Set();
|
|
71
|
+
for (const el of root.querySelectorAll("input, select, textarea")) {
|
|
72
|
+
const type = (el.type || "").toLowerCase();
|
|
73
|
+
if (["hidden", "file", "submit", "button"].includes(type) || !shown(el)) continue;
|
|
74
|
+
if (el.closest("ukg-input, ukg-input-container, [data-automation=upload-resume]")) continue;
|
|
75
|
+
const id = type === "radio" ? el.name : el.id || el.name || el.dataset.automation;
|
|
76
|
+
if (!id || seen.has(id)) continue;
|
|
77
|
+
seen.add(id);
|
|
78
|
+
const label = labelOf(el);
|
|
79
|
+
const field = {id, label: clean(label?.textContent), required: required(el, label),
|
|
80
|
+
kind: type, options: [], value: el.value,
|
|
81
|
+
readonly: el.readOnly || el.disabled, maxLength: el.maxLength > 0 ?
|
|
82
|
+
el.maxLength : null};
|
|
83
|
+
if (el.tagName === "SELECT") {
|
|
84
|
+
field.kind = "select";
|
|
85
|
+
field.options = [...el.options].filter(o => o.value).map(o => clean(o.textContent));
|
|
86
|
+
field.value = el.value ? clean(el.selectedOptions[0]?.textContent) : "";
|
|
87
|
+
} else if (el.tagName === "TEXTAREA") field.kind = "text";
|
|
88
|
+
else if (type === "radio") {
|
|
89
|
+
const all = [...root.querySelectorAll(`input[type=radio][name="${CSS.escape(id)}"]`)];
|
|
90
|
+
field.options = all.map(r => clean(r.closest("label")?.textContent));
|
|
91
|
+
field.required = all.some(r => r.required) || field.required;
|
|
92
|
+
field.value = clean(all.find(r => r.checked)?.closest("label")?.textContent);
|
|
93
|
+
} else if (type === "checkbox") {
|
|
94
|
+
field.label = clean(el.closest("label")?.textContent) || field.label;
|
|
95
|
+
field.value = el.checked ? "Yes" : "No";
|
|
96
|
+
} else if (type === "tel") field.kind = "phone";
|
|
97
|
+
else if (["text", "email", "number", ""].includes(type)) field.kind = "text";
|
|
98
|
+
out.push(field);
|
|
99
|
+
}
|
|
100
|
+
for (const box of root.querySelectorAll("ukg-input[type=date]")) {
|
|
101
|
+
if (!shown(box)) continue;
|
|
102
|
+
const bridge = box.closest("react-ko-es6-bridge") || box.parentElement;
|
|
103
|
+
const named = bridge.querySelector("[id]");
|
|
104
|
+
const id = named?.id;
|
|
105
|
+
if (!id || seen.has(id)) continue;
|
|
106
|
+
seen.add(id);
|
|
107
|
+
const label = bridge.closest(".form-group")?.querySelector("label") || named;
|
|
108
|
+
out.push({id, label: clean(label.textContent), kind: "date",
|
|
109
|
+
required: box.getAttribute("required") === "true" ||
|
|
110
|
+
!!label.classList?.contains("required"),
|
|
111
|
+
options: [], value: box.getAttribute("value") || ""});
|
|
112
|
+
}
|
|
113
|
+
const days = [...root.querySelectorAll("[data-automation=day-btn]")].filter(shown);
|
|
114
|
+
if (days.length) {
|
|
115
|
+
const label = document.getElementById("AvailabilityDaysQuestion");
|
|
116
|
+
out.push({id: "availability_days", kind: "days", required: false,
|
|
117
|
+
label: clean(label?.textContent) || "Which days are you available to work?",
|
|
118
|
+
options: days.map(d => clean(d.textContent)),
|
|
119
|
+
value: days.filter(d => d.classList.contains("selected"))
|
|
120
|
+
.map(d => clean(d.textContent))});
|
|
121
|
+
}
|
|
122
|
+
const upload = [...root.querySelectorAll("form[action*='/Documents/UploadFile']")]
|
|
123
|
+
.find(f => shown(f.closest(".panel, section, div") || f));
|
|
124
|
+
if (upload) {
|
|
125
|
+
const input = upload.querySelector("input[type=file]");
|
|
126
|
+
out.push({id: "documents", kind: "file", label: "Documents", required: false,
|
|
127
|
+
options: [], accept: (input?.accept || "").split(",").filter(Boolean),
|
|
128
|
+
value: ""});
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}"""
|
|
132
|
+
|
|
133
|
+
# What each listed field shows now, by its kind.
|
|
134
|
+
_READ_BACK = """(root, kinds) => {
|
|
135
|
+
const clean = s => (s || "").replace(/\\s+/g, " ").trim();
|
|
136
|
+
const shown = el => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
|
|
137
|
+
const out = {};
|
|
138
|
+
for (const [id, kind] of Object.entries(kinds)) {
|
|
139
|
+
if (kind === "days") {
|
|
140
|
+
out[id] = [...root.querySelectorAll("[data-automation=day-btn]")].filter(shown)
|
|
141
|
+
.filter(d => d.classList.contains("selected")).map(d => clean(d.textContent));
|
|
142
|
+
} else if (kind === "file") {
|
|
143
|
+
out[id] = [...root.querySelectorAll("[data-automation=file-name]")].filter(shown)
|
|
144
|
+
.map(f => clean([...f.querySelectorAll("[id^=FileName], [data-automation=file-link]")]
|
|
145
|
+
.filter(shown).map(e => e.textContent).join(""))).filter(Boolean).pop() || null;
|
|
146
|
+
} else if (kind === "date") {
|
|
147
|
+
const named = root.querySelector(`[id="${CSS.escape(id)}"]`);
|
|
148
|
+
const box = named?.closest("react-ko-es6-bridge, div")?.querySelector("ukg-input");
|
|
149
|
+
out[id] = box?.getAttribute("value") || null;
|
|
150
|
+
} else if (kind === "radio") {
|
|
151
|
+
const checked = [...root.querySelectorAll(`input[type=radio][name="${CSS.escape(id)}"]`)]
|
|
152
|
+
.find(r => r.checked);
|
|
153
|
+
out[id] = checked ? clean(checked.closest("label")?.textContent) : null;
|
|
154
|
+
} else {
|
|
155
|
+
const el = [...root.querySelectorAll(`[id="${CSS.escape(id)}"], [name="${CSS.escape(id)}"]`)]
|
|
156
|
+
.find(shown);
|
|
157
|
+
if (!el) continue;
|
|
158
|
+
if (kind === "select") out[id] = el.value ? clean(el.selectedOptions[0]?.textContent) : null;
|
|
159
|
+
else if (kind === "checkbox") out[id] = el.checked ? "Yes" : "No";
|
|
160
|
+
else out[id] = el.value;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}"""
|
|
165
|
+
|
|
166
|
+
# Each country's states from the board's states URL, by the country's name.
|
|
167
|
+
_STATES = """async (root, url) => {
|
|
168
|
+
const select = root.querySelector("select#Country");
|
|
169
|
+
const countries = [...(select?.options || [])].filter(o => o.value);
|
|
170
|
+
const lists = await Promise.all(countries.map(async o => {
|
|
171
|
+
const response = await fetch(`${url}?countryId=${o.value}`, {credentials: "include"});
|
|
172
|
+
return response.ok ? await response.json() : [];
|
|
173
|
+
}));
|
|
174
|
+
return Object.fromEntries(countries.map((o, i) => [
|
|
175
|
+
o.textContent.replace(/\\s+/g, " ").trim(), (lists[i] || []).map(s => s.Name)]));
|
|
176
|
+
}"""
|
|
177
|
+
|
|
178
|
+
KINDS = {
|
|
179
|
+
"text": FieldType.TEXT,
|
|
180
|
+
"phone": FieldType.PHONE,
|
|
181
|
+
"select": FieldType.CHOICE,
|
|
182
|
+
"radio": FieldType.CHOICE,
|
|
183
|
+
"checkbox": FieldType.CHOICE,
|
|
184
|
+
"date": FieldType.DATE,
|
|
185
|
+
"days": FieldType.MULTI_CHOICE,
|
|
186
|
+
"file": FieldType.FILE,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass(frozen=True)
|
|
191
|
+
class Board:
|
|
192
|
+
"""A tenant's job board, where its postings and its sign-in are served."""
|
|
193
|
+
|
|
194
|
+
host: str
|
|
195
|
+
tenant: str # lowercase
|
|
196
|
+
board: str
|
|
197
|
+
opportunity: str
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def base(self) -> str:
|
|
201
|
+
return f"https://{self.host}/{self.tenant}/JobBoard/{self.board}"
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def apply(self) -> str:
|
|
205
|
+
return f"{self.base}/OpportunityApply?opportunityId={self.opportunity}"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def board(url: str) -> Board | None:
|
|
209
|
+
parts = urlsplit(url)
|
|
210
|
+
host = (parts.hostname or "").lower()
|
|
211
|
+
match = PATH.match(parts.path)
|
|
212
|
+
opportunity = parse_qs(parts.query).get("opportunityId", [""])[0].lower()
|
|
213
|
+
if not HOST.match(host) or not match or not opportunity:
|
|
214
|
+
return None
|
|
215
|
+
return Board(host, match["tenant"].lower(), match["board"].lower(), opportunity)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class UKG:
|
|
219
|
+
name = "ukg"
|
|
220
|
+
per_employer = True
|
|
221
|
+
asks_name = True
|
|
222
|
+
known_gaps = {PHONE_GAP: Gap.FLOW}
|
|
223
|
+
side_effects = [
|
|
224
|
+
SideEffect("sign_up", "employer", "the account is created in the employer's tenant"),
|
|
225
|
+
SideEffect("upload", "hiring_system", "a document is stored in the account's documents"),
|
|
226
|
+
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
227
|
+
]
|
|
228
|
+
|
|
229
|
+
def __init__(self) -> None:
|
|
230
|
+
self._boards: dict[str, Board] = {} # by tenant, the last posting's
|
|
231
|
+
self._kinds: dict[str, str] = {}
|
|
232
|
+
|
|
233
|
+
def detect(self, url: str, page: Page | None) -> bool:
|
|
234
|
+
return board(url) is not None
|
|
235
|
+
|
|
236
|
+
def posting(self, url: str) -> str:
|
|
237
|
+
found = board(url)
|
|
238
|
+
return f"{found.base}/OpportunityDetail?opportunityId={found.opportunity}" if found else url
|
|
239
|
+
|
|
240
|
+
def account_scope(self, url: str) -> str | None:
|
|
241
|
+
found = board(url)
|
|
242
|
+
if found is None:
|
|
243
|
+
return None
|
|
244
|
+
self._boards[found.tenant] = found
|
|
245
|
+
return found.tenant
|
|
246
|
+
|
|
247
|
+
def login_url(self, scope: str | None) -> str:
|
|
248
|
+
"""The board's sign-in, back to the last posting's form once signed in."""
|
|
249
|
+
if scope not in self._boards:
|
|
250
|
+
raise ValueError(f"no UKG posting seen for the tenant {scope!r}")
|
|
251
|
+
found = self._boards[scope]
|
|
252
|
+
back = urlsplit(found.apply)
|
|
253
|
+
return f"{found.base}/Account/Login?redirectUrl={quote(f'{back.path}?{back.query}')}"
|
|
254
|
+
|
|
255
|
+
def signed_in(self, request: Request, scope: str | None) -> bool:
|
|
256
|
+
"""The posting's form loads, instead of redirecting to the sign-in or to the tenant's
|
|
257
|
+
name step; without a posting
|
|
258
|
+
seen, the tenant's session cookie is there and unexpired."""
|
|
259
|
+
if scope in self._boards:
|
|
260
|
+
response = request.get(self._boards[scope].apply)
|
|
261
|
+
return response.ok and urlsplit(response.url).path.endswith("/OpportunityApply")
|
|
262
|
+
cookies = request.storage_state()["cookies"]
|
|
263
|
+
return any(
|
|
264
|
+
c["name"] == SESSION_COOKIE and HOST.match(c["domain"].lstrip("."))
|
|
265
|
+
and (c["expires"] < 0 or c["expires"] > time.time())
|
|
266
|
+
for c in cookies
|
|
267
|
+
) # fmt: skip
|
|
268
|
+
|
|
269
|
+
def sign_in(self, page: Page, email: str, password: str) -> None:
|
|
270
|
+
page.locator("input#username").wait_for(timeout=FORM_TIMEOUT_MS)
|
|
271
|
+
page.locator("input#username").fill(email)
|
|
272
|
+
page.locator("input#password").fill(password)
|
|
273
|
+
page.locator("button[type=submit][name=action][data-action-button-primary]").click()
|
|
274
|
+
_left_sign_in(page)
|
|
275
|
+
if "/AuthCode/Register" in page.url:
|
|
276
|
+
raise Unsupported("unhandled_rendering: the tenant asks the account's name")
|
|
277
|
+
|
|
278
|
+
def sign_up(self, page: Page, email: str, password: str, name: str | None) -> None:
|
|
279
|
+
page.locator("a[href*='/u/signup']").first.click()
|
|
280
|
+
page.locator("input#email").wait_for(timeout=FORM_TIMEOUT_MS)
|
|
281
|
+
page.locator("input#email").fill(email)
|
|
282
|
+
page.locator("input#password").fill(password)
|
|
283
|
+
page.locator("button[type=submit][name=action][data-action-button-primary]").click()
|
|
284
|
+
_left_sign_in(page)
|
|
285
|
+
if "/AuthCode/Register" not in page.url:
|
|
286
|
+
return
|
|
287
|
+
_register(page, name or "")
|
|
288
|
+
|
|
289
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
290
|
+
found = board(page.url)
|
|
291
|
+
if found is None or not urlsplit(page.url).path.endswith("/OpportunityApply"):
|
|
292
|
+
found = found or next(iter(self._boards.values()), None)
|
|
293
|
+
if found is None:
|
|
294
|
+
raise Unsupported("unhandled_rendering: not a UKG posting page")
|
|
295
|
+
page.goto(found.apply)
|
|
296
|
+
if SIGN_IN_HOST.match(urlsplit(page.url).hostname or ""):
|
|
297
|
+
raise Blocked("login_wall: the form redirects to the sign-in")
|
|
298
|
+
try:
|
|
299
|
+
page.locator(READY).wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
|
|
300
|
+
except Timeout:
|
|
301
|
+
raise Unsupported("posting_gone: no application form on the page") from None
|
|
302
|
+
page.wait_for_timeout(SETTLE_MS)
|
|
303
|
+
raws = page.locator(FORM).evaluate(_READ)
|
|
304
|
+
required_resume = '"IsResumeRequired":true' in page.content()
|
|
305
|
+
states = None
|
|
306
|
+
if page.locator(f"{FORM} select#{STATE}").count():
|
|
307
|
+
states = page.locator(FORM).evaluate(_STATES, f"{found.base}/Candidate/GetStates")
|
|
308
|
+
if not any(r["id"] == STATE for r in raws): # shown once a country is picked
|
|
309
|
+
label = page.locator(f"{FORM} label[for={STATE}]").first
|
|
310
|
+
at = next((i + 1 for i, r in enumerate(raws) if r["id"] == "City"), len(raws))
|
|
311
|
+
raws.insert(at, {
|
|
312
|
+
"id": STATE, "label": " ".join((label.text_content() or "").split()),
|
|
313
|
+
"kind": "select", "options": [], "value": "",
|
|
314
|
+
"required": "required" in (label.get_attribute("class") or ""),
|
|
315
|
+
}) # fmt: skip
|
|
316
|
+
self._kinds = {raw["id"]: raw["kind"] for raw in raws}
|
|
317
|
+
fields = []
|
|
318
|
+
for raw in raws:
|
|
319
|
+
if raw["id"] == DOCUMENTS:
|
|
320
|
+
raw["required"] = required_resume
|
|
321
|
+
fields.append(_field(raw, states))
|
|
322
|
+
return fields
|
|
323
|
+
|
|
324
|
+
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
325
|
+
for field_id, kind in self._kinds.items():
|
|
326
|
+
if field_id not in answers:
|
|
327
|
+
continue
|
|
328
|
+
answer = answers[field_id]
|
|
329
|
+
values = [answer] if isinstance(answer, str) else answer
|
|
330
|
+
root = page.locator(FORM)
|
|
331
|
+
match kind:
|
|
332
|
+
case "select":
|
|
333
|
+
control = _control(root, field_id)
|
|
334
|
+
control.select_option(label=values[0])
|
|
335
|
+
if field_id == COUNTRY:
|
|
336
|
+
_wait_for_states(page)
|
|
337
|
+
case "radio":
|
|
338
|
+
_option(root, field_id, values[0]).check()
|
|
339
|
+
case "checkbox":
|
|
340
|
+
_control(root, field_id).set_checked(values[0] == "Yes")
|
|
341
|
+
case "date":
|
|
342
|
+
_date(root, field_id, values[0])
|
|
343
|
+
case "days":
|
|
344
|
+
for day in root.locator("[data-automation=day-btn]:visible").all():
|
|
345
|
+
text = (day.text_content() or "").strip()
|
|
346
|
+
if ("selected" in (day.get_attribute("class") or "")) != (text in values):
|
|
347
|
+
day.click()
|
|
348
|
+
case "file":
|
|
349
|
+
_upload(page, values[0])
|
|
350
|
+
case _:
|
|
351
|
+
_control(root, field_id).fill(values[0])
|
|
352
|
+
|
|
353
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
354
|
+
values = page.locator(FORM).evaluate(_READ_BACK, self._kinds)
|
|
355
|
+
return {k: v for k, v in values.items() if v not in (None, "", [])}
|
|
356
|
+
|
|
357
|
+
def submit(self, page: Page) -> str | None:
|
|
358
|
+
"""Clicks Submit; the application posts and the page moves to ApplicationSubmitted,
|
|
359
|
+
the evidence. A tenant may have Cloudflare Turnstile check the visitor first."""
|
|
360
|
+
page.locator(READY).click()
|
|
361
|
+
try:
|
|
362
|
+
page.wait_for_url(re.compile(r"/ApplicationSubmitted"), timeout=SUBMIT_TIMEOUT_MS)
|
|
363
|
+
except Timeout:
|
|
364
|
+
if page.locator("iframe[src*='challenges.cloudflare.com']").is_visible():
|
|
365
|
+
raise Blocked("captcha: Cloudflare Turnstile asked for a challenge") from None
|
|
366
|
+
raise
|
|
367
|
+
return page.url
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _left_sign_in(page: Page) -> None:
|
|
371
|
+
"""Waits to leave the sign-in host, or for the error the page shows."""
|
|
372
|
+
deadline = time.monotonic() + SIGN_IN_TIMEOUT_S
|
|
373
|
+
said = page.locator(".ulp-input-error-message:visible, #prompt-alert:visible, "
|
|
374
|
+
"[id^=error-element-]:visible") # fmt: skip
|
|
375
|
+
while time.monotonic() < deadline:
|
|
376
|
+
host = urlsplit(page.url).hostname or ""
|
|
377
|
+
path = urlsplit(page.url).path
|
|
378
|
+
if not SIGN_IN_HOST.match(host):
|
|
379
|
+
page.wait_for_load_state()
|
|
380
|
+
return
|
|
381
|
+
if re.search(r"/u/(mfa|email-verification|login-email-verification)", path):
|
|
382
|
+
raise Blocked("email_otp: the sign-in asks for a code sent by email")
|
|
383
|
+
if page.locator("[data-captcha-provider]:visible, .ulp-captcha:visible").count():
|
|
384
|
+
raise Blocked("captcha: the sign-in page shows a captcha")
|
|
385
|
+
if said.count():
|
|
386
|
+
text = " ".join(said.all_inner_texts()).strip()
|
|
387
|
+
codes = (
|
|
388
|
+
" ".join(filter(None, [e.get_attribute("data-error-code") for e in said.all()]))
|
|
389
|
+
+ " "
|
|
390
|
+
+ " ".join(filter(None, [e.get_attribute("id") for e in said.all()]))
|
|
391
|
+
)
|
|
392
|
+
for pattern, error, code in ERRORS:
|
|
393
|
+
if pattern.search(codes.lower()) or pattern.search(text.lower()):
|
|
394
|
+
raise error(f"{code}: {text}")
|
|
395
|
+
raise Unsupported(f"unhandled_rendering: the sign-in page says {text!r}")
|
|
396
|
+
page.wait_for_timeout(SIGN_IN_POLL_MS)
|
|
397
|
+
raise Unsupported("unhandled_rendering: the sign-in page answered nothing")
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _register(page: Page, name: str) -> None:
|
|
401
|
+
"""The tenant's own step after the sign-up: the name, and the privacy policy's box on some
|
|
402
|
+
tenants; the box for text messages is left. Its inputs take typed keys only: a filled value
|
|
403
|
+
leaves Create account disabled."""
|
|
404
|
+
page.locator("input[name=firstName]").wait_for(timeout=FORM_TIMEOUT_MS)
|
|
405
|
+
page.wait_for_load_state("networkidle")
|
|
406
|
+
if page.locator("ukg-input[name=phoneNumber][required=true]").count():
|
|
407
|
+
raise Unsupported(f"{Gap.FLOW}: {PHONE_GAP}: the tenant's sign-up asks a phone number")
|
|
408
|
+
first, _, last = name.strip().partition(" ")
|
|
409
|
+
for field, value in (("firstName", first), ("lastName", last or first)):
|
|
410
|
+
page.locator(f"input[name={field}]").click()
|
|
411
|
+
page.locator(f"input[name={field}]").press_sequentially(value)
|
|
412
|
+
privacy = page.locator("ukg-checkbox[data-automation*=privacy]:visible")
|
|
413
|
+
for box in privacy.all():
|
|
414
|
+
if box.get_attribute("aria-checked") != "true":
|
|
415
|
+
box.click()
|
|
416
|
+
page.get_by_role("button", name="Create account").click()
|
|
417
|
+
page.wait_for_url(lambda u: "/AuthCode/Register" not in u, timeout=FORM_TIMEOUT_MS)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _field(raw: dict[str, Any], states: dict[str, list[str]] | None) -> Field:
|
|
421
|
+
kind, field_id = raw["kind"], raw["id"]
|
|
422
|
+
label = raw["label"] or field_id
|
|
423
|
+
if kind not in KINDS:
|
|
424
|
+
reason = f"unhandled_rendering: {field_id} is a {kind} input"
|
|
425
|
+
return Field(field_id, label, FieldType.TEXT, raw["required"], unfillable=reason)
|
|
426
|
+
value = raw.get("value") or None
|
|
427
|
+
if kind == "checkbox":
|
|
428
|
+
return Field(field_id, label, FieldType.CHOICE, raw["required"], YES_NO, default="No")
|
|
429
|
+
if kind == "days":
|
|
430
|
+
return Field(field_id, label, FieldType.MULTI_CHOICE, False, raw["options"])
|
|
431
|
+
if kind == "file":
|
|
432
|
+
return Field(field_id, label, FieldType.FILE, raw["required"], accept=raw["accept"])
|
|
433
|
+
if raw.get("readonly"): # the account's name, changed on its profile only
|
|
434
|
+
reason = f"unsupported_rendering: {field_id} is read-only" if not value else None
|
|
435
|
+
return Field(field_id, label, KINDS[kind], raw["required"], prefilled=value,
|
|
436
|
+
unfillable=reason) # fmt: skip
|
|
437
|
+
options = raw["options"] if kind in ("select", "radio") else None
|
|
438
|
+
if field_id == STATE and states is not None:
|
|
439
|
+
sets = [OptionSet(Is(COUNTRY, [country]), names) for country, names in states.items()]
|
|
440
|
+
return Field(field_id, label, FieldType.CHOICE, raw["required"], [], option_sets=sets)
|
|
441
|
+
return Field(
|
|
442
|
+
field_id, label, KINDS[kind], raw["required"], options, raw.get("maxLength"),
|
|
443
|
+
default=value if kind in ("date", "select") else None,
|
|
444
|
+
) # fmt: skip
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _wait_for_states(page: Page) -> None:
|
|
448
|
+
with suppress(Timeout): # a country without states
|
|
449
|
+
page.wait_for_function(
|
|
450
|
+
"() => document.querySelectorAll('#OpportunityApply select#State option').length > 1",
|
|
451
|
+
timeout=STATES_TIMEOUT_MS,
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _control(root: Locator, field_id: str) -> Locator:
|
|
456
|
+
return root.locator(f'[id="{field_id}"]:visible, [name="{field_id}"]:visible').first
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _option(root: Locator, name: str, text: str) -> Locator:
|
|
460
|
+
for radio in root.locator(f'input[type=radio][name="{name}"]').all():
|
|
461
|
+
label = radio.locator("xpath=ancestor::label[1]").text_content() or ""
|
|
462
|
+
if " ".join(label.split()) == text:
|
|
463
|
+
return radio
|
|
464
|
+
raise NotTaken(f"no option {text!r} for {name}")
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _date(root: Locator, field_id: str, value: str) -> None:
|
|
468
|
+
"""Types the date into the picker's month, day and year inputs."""
|
|
469
|
+
year, month, day = value.split("-")
|
|
470
|
+
bridge = root.locator(f'[id="{field_id}"]').locator("xpath=ancestor::react-ko-es6-bridge[1]")
|
|
471
|
+
for part, typed in (("month", month), ("day", day), ("year", year)):
|
|
472
|
+
segment = bridge.locator(f"[data-automation=ukg-date-input-text-{part}]").first
|
|
473
|
+
segment.click()
|
|
474
|
+
segment.press("Control+a")
|
|
475
|
+
segment.press_sequentially(typed)
|
|
476
|
+
bridge.locator("[data-automation=ukg-date-input-text-year]").first.press("Tab")
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _upload(page: Page, path: str) -> None:
|
|
480
|
+
form = page.locator(f"{FORM} form[action*='/Documents/UploadFile']").first
|
|
481
|
+
name = path.rsplit("/", 1)[-1]
|
|
482
|
+
form.locator("input[type=file]").set_input_files(path)
|
|
483
|
+
page.locator(f"{FORM} [data-automation=file-name]:visible", has_text=name).first.wait_for(
|
|
484
|
+
timeout=UPLOAD_TIMEOUT_MS
|
|
485
|
+
)
|
|
@@ -102,7 +102,9 @@ kind = "ats"
|
|
|
102
102
|
share = 0.0349
|
|
103
103
|
account = "to_read"
|
|
104
104
|
hosting = ["hosted"]
|
|
105
|
+
adapter = "ukg"
|
|
105
106
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
107
|
+
unsupported = { date = 2026-10-26, version = "0.0.3", commit = "a60a17d", by = "agent", reason = "unsupported_flow: sign_up_phone: the tenant's details step after the sign-up requires a phone number, which login does not take (10 of 17 tenants)" }
|
|
106
108
|
|
|
107
109
|
[paycom]
|
|
108
110
|
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
|
|
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
|
|
File without changes
|