atsfill 0.0.4.dev73__tar.gz → 0.0.4.dev76__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.dev73 → atsfill-0.0.4.dev76}/PKG-INFO +2 -2
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev76}/README.md +1 -1
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev76}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev76}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev76}/src/atsfill/adapters/__init__.py +4 -0
- atsfill-0.0.4.dev76/src/atsfill/adapters/personio.py +293 -0
- atsfill-0.0.4.dev76/src/atsfill/adapters/pinpoint.py +551 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev76}/src/atsfill/registry.toml +4 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev76}/LICENSE +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev76}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev76}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev76}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev76}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev76}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev76}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev76}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev76}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev76}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev76}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev76}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev76}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev76}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev76}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev76}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev76}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev76}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev76}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev76}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev76}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev76}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev76}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev76}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev76}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev76}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev76}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev76}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev76}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev76}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev76}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev76}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev76}/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.dev76
|
|
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
|
|
@@ -54,7 +54,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
54
54
|
## Supported hiring systems
|
|
55
55
|
|
|
56
56
|
<!-- badges -->
|
|
57
|
-
                  
|
|
57
|
+
                    
|
|
58
58
|
<!-- /badges -->
|
|
59
59
|
|
|
60
60
|
| 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 |
|
|
@@ -10,7 +10,9 @@ from atsfill.adapters.manatal import Manatal
|
|
|
10
10
|
from atsfill.adapters.oracle import Oracle
|
|
11
11
|
from atsfill.adapters.paycor import Paycor
|
|
12
12
|
from atsfill.adapters.paylocity import Paylocity
|
|
13
|
+
from atsfill.adapters.personio import Personio
|
|
13
14
|
from atsfill.adapters.phenom import Phenom
|
|
15
|
+
from atsfill.adapters.pinpoint import Pinpoint
|
|
14
16
|
from atsfill.adapters.recruitee import Recruitee
|
|
15
17
|
from atsfill.adapters.smartrecruiters import SmartRecruiters
|
|
16
18
|
from atsfill.adapters.teamtailor import Teamtailor
|
|
@@ -41,5 +43,7 @@ def adapters() -> list[Adapter]:
|
|
|
41
43
|
Breezy(),
|
|
42
44
|
BambooHR(),
|
|
43
45
|
ZohoRecruit(),
|
|
46
|
+
Personio(),
|
|
47
|
+
Pinpoint(),
|
|
44
48
|
Phenom(), # last: it claims a URL by its path alone
|
|
45
49
|
]
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Personio (`<employer>.jobs.personio.com/job/<id>`), as docs/adapters/personio.md observes.
|
|
2
|
+
|
|
3
|
+
The apply page is rendered on the server, its form whole in the HTML: every field is one native
|
|
4
|
+
control named by its id, labelled by a `label[for]` or its group's label, a star marking it
|
|
5
|
+
required. `read_api` fetches that HTML over HTTP, `read_form` takes the page's form, and both
|
|
6
|
+
list the fields from it with one parser. A document is uploaded to Personio once attached.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import urllib.request
|
|
11
|
+
from contextlib import suppress
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from html.parser import HTMLParser
|
|
14
|
+
from urllib.parse import urlsplit
|
|
15
|
+
|
|
16
|
+
from atsfill.core.adapter import Blocked, Gap, Unsupported
|
|
17
|
+
from atsfill.core.browser import HTTP_TIMEOUT_S, Locator, Page, Timeout
|
|
18
|
+
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
19
|
+
|
|
20
|
+
HOST = re.compile(r"^([a-z0-9-]+)\.jobs\.personio\.(?:com|de)$")
|
|
21
|
+
JOB = re.compile(r"^/job/(\d+)(?:/apply)?/?$")
|
|
22
|
+
APPLIED = re.compile(r"/api/v1/jobs/\d+/application$")
|
|
23
|
+
FORM = "form:has(button[type=submit])"
|
|
24
|
+
SUBMIT = f"{FORM} button[type=submit]"
|
|
25
|
+
FORM_TIMEOUT_MS = 30_000
|
|
26
|
+
UPLOAD_TIMEOUT_MS = 30_000
|
|
27
|
+
ENABLED_TIMEOUT_MS = 30_000
|
|
28
|
+
SUBMIT_TIMEOUT_MS = 90_000
|
|
29
|
+
PHONE = "phone"
|
|
30
|
+
# Each control the form renders, by its tag or input type, and its field type.
|
|
31
|
+
KINDS = {
|
|
32
|
+
"text": FieldType.TEXT,
|
|
33
|
+
"email": FieldType.TEXT,
|
|
34
|
+
"date": FieldType.DATE,
|
|
35
|
+
"select": FieldType.CHOICE,
|
|
36
|
+
"file": FieldType.FILE,
|
|
37
|
+
}
|
|
38
|
+
VOID = {"input", "br", "img", "hr", "meta", "link", "source", "wbr", "col", "area"}
|
|
39
|
+
|
|
40
|
+
# What each control holds: a value, a select's option value, a document's uploaded names.
|
|
41
|
+
_READ_BACK = """(form, ids) => {
|
|
42
|
+
const out = {};
|
|
43
|
+
for (const id of ids) {
|
|
44
|
+
const el = form.querySelector(`[name="${id}"]`);
|
|
45
|
+
if (!el) continue;
|
|
46
|
+
if (el.type === "file") {
|
|
47
|
+
const group = el.closest("[role=group]");
|
|
48
|
+
out[id] = [...(group?.querySelectorAll("[role=listitem] span") ?? [])]
|
|
49
|
+
.map(e => e.textContent.trim());
|
|
50
|
+
} else out[id] = el.value;
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}"""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Personio:
|
|
57
|
+
name = "personio"
|
|
58
|
+
known_gaps: dict[str, Gap] = {}
|
|
59
|
+
side_effects = [
|
|
60
|
+
SideEffect("upload", "hiring_system", "a document is uploaded to Personio at once"),
|
|
61
|
+
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
def __init__(self) -> None:
|
|
65
|
+
self._options: dict[str, dict[str, str]] = {} # a choice's option texts by value
|
|
66
|
+
self._fields: list[Field] = []
|
|
67
|
+
|
|
68
|
+
def detect(self, url: str, page: Page | None) -> bool:
|
|
69
|
+
return _job(url) is not None
|
|
70
|
+
|
|
71
|
+
def posting(self, url: str) -> str:
|
|
72
|
+
job = _job(url)
|
|
73
|
+
return f"https://{job[0]}.jobs.personio.com/job/{job[1]}" if job else url
|
|
74
|
+
|
|
75
|
+
def read_api(self, url: str) -> list[Field] | None:
|
|
76
|
+
if _job(url) is None:
|
|
77
|
+
return None
|
|
78
|
+
request = urllib.request.Request(_apply(url), headers={"User-Agent": "atsfill"})
|
|
79
|
+
try:
|
|
80
|
+
with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
|
|
81
|
+
html = response.read().decode()
|
|
82
|
+
except OSError, ValueError:
|
|
83
|
+
return None
|
|
84
|
+
form = next((n for n in _parse(html).walk() if n.tag == "form"), None)
|
|
85
|
+
return [f for f, _ in _form(form)] if form else None
|
|
86
|
+
|
|
87
|
+
def read_form(self, page: Page) -> list[Field]:
|
|
88
|
+
if _job(page.url) and not urlsplit(page.url).path.rstrip("/").endswith("/apply"):
|
|
89
|
+
page.goto(_apply(page.url))
|
|
90
|
+
try:
|
|
91
|
+
page.locator(SUBMIT).wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
|
|
92
|
+
except Timeout:
|
|
93
|
+
raise Unsupported("posting_gone: no application form on the page") from None
|
|
94
|
+
listed = _form(_parse(page.locator(FORM).evaluate("f => f.outerHTML")))
|
|
95
|
+
self._options = {f.id: o for f, o in listed if o and not f.unfillable}
|
|
96
|
+
self._fields = [f for f, _ in listed if not f.unfillable]
|
|
97
|
+
return [f for f, _ in listed]
|
|
98
|
+
|
|
99
|
+
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
100
|
+
form = page.locator(FORM)
|
|
101
|
+
for f in self._fields:
|
|
102
|
+
if f.id not in answers:
|
|
103
|
+
continue
|
|
104
|
+
answer = answers[f.id]
|
|
105
|
+
value = answer if isinstance(answer, str) else answer[0]
|
|
106
|
+
control = form.locator(f"[name='{f.id}']")
|
|
107
|
+
match f.type:
|
|
108
|
+
case FieldType.FILE:
|
|
109
|
+
_attach(control, value)
|
|
110
|
+
case FieldType.CHOICE:
|
|
111
|
+
by_text = {t: v for v, t in self._options[f.id].items()}
|
|
112
|
+
control.select_option(value=by_text.get(value, value))
|
|
113
|
+
case _:
|
|
114
|
+
control.fill(value)
|
|
115
|
+
|
|
116
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
117
|
+
values = page.locator(FORM).evaluate(_READ_BACK, [f.id for f in self._fields])
|
|
118
|
+
out: dict[str, Answer] = {}
|
|
119
|
+
for field_id, value in values.items():
|
|
120
|
+
if field_id in self._options:
|
|
121
|
+
value = self._options[field_id].get(value, value)
|
|
122
|
+
elif isinstance(value, list): # a document's last upload
|
|
123
|
+
value = value[-1] if value else None
|
|
124
|
+
if value not in (None, ""):
|
|
125
|
+
out[field_id] = value
|
|
126
|
+
return out
|
|
127
|
+
|
|
128
|
+
def submit(self, page: Page) -> str | None:
|
|
129
|
+
"""Waits for submit to be enabled (Turnstile), clicks it; the evidence is the answer to the
|
|
130
|
+
application's POST."""
|
|
131
|
+
button = page.locator(SUBMIT)
|
|
132
|
+
try:
|
|
133
|
+
page.wait_for_function(
|
|
134
|
+
"el => !el.disabled", arg=button.element_handle(), timeout=ENABLED_TIMEOUT_MS
|
|
135
|
+
)
|
|
136
|
+
except Timeout:
|
|
137
|
+
raise Blocked("captcha: Turnstile kept the submit disabled") from None
|
|
138
|
+
with page.expect_response(
|
|
139
|
+
lambda r: (
|
|
140
|
+
r.request.method == "POST" and APPLIED.search(urlsplit(r.url).path) is not None
|
|
141
|
+
),
|
|
142
|
+
timeout=SUBMIT_TIMEOUT_MS,
|
|
143
|
+
) as applied:
|
|
144
|
+
button.click()
|
|
145
|
+
response = applied.value
|
|
146
|
+
if not response.ok:
|
|
147
|
+
raise RuntimeError(f"apply answered {response.status}: {response.text()[:200]}")
|
|
148
|
+
return f"{response.url} {response.status}"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _job(url: str) -> tuple[str, str] | None:
|
|
152
|
+
"""The account and job id, from `<account>.jobs.personio.{com,de}/job/<id>[/apply]`."""
|
|
153
|
+
parts = urlsplit(url)
|
|
154
|
+
host, path = HOST.match(parts.hostname or ""), JOB.match(parts.path)
|
|
155
|
+
return (host[1], path[1]) if host and path else None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _apply(url: str) -> str:
|
|
159
|
+
"""The posting's apply page, in the language the URL asks for."""
|
|
160
|
+
parts = urlsplit(url)
|
|
161
|
+
job = JOB.match(parts.path)
|
|
162
|
+
assert job is not None
|
|
163
|
+
language = re.search(r"(?:^|&)(language=[\w-]+)", parts.query)
|
|
164
|
+
query = f"?{language[1]}" if language else ""
|
|
165
|
+
return f"https://{parts.hostname}/job/{job[1]}/apply{query}"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass
|
|
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]]:
|
|
235
|
+
"""Each named control of the form, with a choice's option texts by value."""
|
|
236
|
+
nodes = form.walk()
|
|
237
|
+
by_id = {n.attrs["id"]: n for n in nodes if "id" in n.attrs}
|
|
238
|
+
labels = {n.attrs["for"]: n for n in nodes if n.tag == "label" and "for" in n.attrs}
|
|
239
|
+
out: list[tuple[Field, dict[str, str] | None]] = []
|
|
240
|
+
for node in nodes:
|
|
241
|
+
name = node.attrs.get("name")
|
|
242
|
+
if node.tag not in ("input", "select", "textarea") or not name:
|
|
243
|
+
continue
|
|
244
|
+
kind = node.attrs.get("type", "text") if node.tag == "input" else node.tag
|
|
245
|
+
if kind == "hidden":
|
|
246
|
+
continue
|
|
247
|
+
group = node.ancestor("group")
|
|
248
|
+
group_label = by_id.get(group.attrs.get("aria-labelledby", "")) if group else None
|
|
249
|
+
own = labels.get(node.attrs.get("id", ""))
|
|
250
|
+
label = (own.label() if own else "") or (group_label.label() if group_label else "") or name
|
|
251
|
+
required = any(n is not None and n.starred() for n in (own, group_label))
|
|
252
|
+
if kind not in KINDS or "multiple" in node.attrs and kind == "select":
|
|
253
|
+
reason = f"unhandled_rendering: {name} is a {kind}"
|
|
254
|
+
out.append((Field(name, label, FieldType.TEXT, required, unfillable=reason), None))
|
|
255
|
+
continue
|
|
256
|
+
field_type = FieldType.PHONE if name == PHONE else KINDS[kind]
|
|
257
|
+
options = None
|
|
258
|
+
if kind == "select":
|
|
259
|
+
options = {
|
|
260
|
+
o.attrs["value"]: " ".join(o.text().split())
|
|
261
|
+
for o in node.walk()
|
|
262
|
+
if o.tag == "option" and o.attrs.get("value")
|
|
263
|
+
}
|
|
264
|
+
max_length = int(node.attrs.get("maxlength", 0)) or None
|
|
265
|
+
accept = (
|
|
266
|
+
node.attrs["accept"].split(",") if kind == "file" and "accept" in node.attrs else None
|
|
267
|
+
)
|
|
268
|
+
out.append(
|
|
269
|
+
(
|
|
270
|
+
Field(
|
|
271
|
+
name,
|
|
272
|
+
label,
|
|
273
|
+
field_type,
|
|
274
|
+
required,
|
|
275
|
+
list(options.values()) if options else None,
|
|
276
|
+
max_length if field_type in (FieldType.TEXT, FieldType.PHONE) else None,
|
|
277
|
+
accept,
|
|
278
|
+
),
|
|
279
|
+
options,
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
return out
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _attach(control: Locator, path: str) -> None:
|
|
286
|
+
"""Attaches a document and waits for Personio's upload to show its name in the category."""
|
|
287
|
+
name = path.rsplit("/", 1)[-1]
|
|
288
|
+
control.set_input_files(path)
|
|
289
|
+
shown = control.locator("xpath=ancestor::*[@role='group'][1]").locator(
|
|
290
|
+
"[role=listitem] span", has_text=name
|
|
291
|
+
)
|
|
292
|
+
with suppress(Timeout):
|
|
293
|
+
shown.first.wait_for(timeout=UPLOAD_TIMEOUT_MS)
|
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
"""Pinpoint (`<employer>.pinpointhq.com` or a career domain, `/<lang>/postings/<uuid>`), as
|
|
2
|
+
docs/adapters/pinpoint.md observes.
|
|
3
|
+
|
|
4
|
+
The apply page is rendered on the server: the standard fields as native controls, every other
|
|
5
|
+
field as a React component whose props sit in a JSON script next to it. `read_api` fetches that
|
|
6
|
+
HTML over HTTP, `read_form` takes the page's form, and both list the fields from it with one
|
|
7
|
+
parser. A document is uploaded to Pinpoint once attached.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import urllib.request
|
|
13
|
+
from contextlib import suppress
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from datetime import date
|
|
16
|
+
from html.parser import HTMLParser
|
|
17
|
+
from typing import Any
|
|
18
|
+
from urllib.parse import urlsplit
|
|
19
|
+
|
|
20
|
+
from atsfill.core.adapter import Gap, NotTaken, Unsupported, known_gap
|
|
21
|
+
from atsfill.core.browser import HTTP_TIMEOUT_S, Locator, Page, Timeout
|
|
22
|
+
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
23
|
+
|
|
24
|
+
HOST = re.compile(r"(?:^|\.)pinpointhq\.com$")
|
|
25
|
+
JOB = re.compile(
|
|
26
|
+
r"^/([a-z]{2}(?:-[a-z]{2})?)/postings/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-"
|
|
27
|
+
r"[0-9a-f]{12})(?:/applications/new)?/?$",
|
|
28
|
+
re.IGNORECASE,
|
|
29
|
+
)
|
|
30
|
+
FORM = "#application-form"
|
|
31
|
+
SUBMIT = f"{FORM} button[type=submit]"
|
|
32
|
+
COMPONENT = "js-react-on-rails-component"
|
|
33
|
+
FORM_TIMEOUT_MS = 30_000
|
|
34
|
+
HYDRATE_TIMEOUT_MS = 30_000
|
|
35
|
+
OPTION_TIMEOUT_MS = 5_000
|
|
36
|
+
SHOWN_TIMEOUT_MS = 2_000
|
|
37
|
+
COOKIE_TIMEOUT_MS = 5_000
|
|
38
|
+
ONETRUST = "#onetrust-consent-sdk"
|
|
39
|
+
US_STATE = ".react-select:has(input[type=hidden][name$='[town]'])" # the town's place in the US
|
|
40
|
+
UPLOAD_TIMEOUT_MS = 30_000
|
|
41
|
+
SUBMIT_TIMEOUT_MS = 90_000
|
|
42
|
+
YES_NO = ["Yes", "No"]
|
|
43
|
+
US = "United States" # the country whose address asks for a city and a state
|
|
44
|
+
JOB_PREFERENCES = "job_preferences"
|
|
45
|
+
TEXT_QUESTIONS = {"Shorttext", "Longtext", "Url", "Numberinput"}
|
|
46
|
+
FILE_QUESTIONS = {"Document", "Multipledocuments"}
|
|
47
|
+
NOT_FIELDS = {"New", "Chatbot", "Stagescarousel"} # components without a field, by last name
|
|
48
|
+
VOID = {"input", "br", "img", "hr", "meta", "link", "source", "wbr", "col", "area"}
|
|
49
|
+
|
|
50
|
+
# What each field holds, by how it renders: a control's value, a react-select's shown value(s),
|
|
51
|
+
# a radio's label, a date's three selects, a document's uploaded names.
|
|
52
|
+
_READ_BACK = r"""(form, specs) => {
|
|
53
|
+
const text = e => e ? e.textContent.replace(/\s+/g, " ").trim() : null;
|
|
54
|
+
const root = s => s.root ? form.querySelector(`[id="${s.root}"]`) : form;
|
|
55
|
+
const control = s => root(s)?.querySelector(`[name="${s.name}"]:not([type=hidden])`);
|
|
56
|
+
const shown = box => {
|
|
57
|
+
const multi = [...(box?.querySelectorAll(".react-select__multi-value__label") ?? [])];
|
|
58
|
+
if (multi.length) return multi.map(text);
|
|
59
|
+
return text(box?.querySelector(".react-select__single-value"));
|
|
60
|
+
};
|
|
61
|
+
const out = {};
|
|
62
|
+
for (const s of specs) {
|
|
63
|
+
const us = !!root(s)?.querySelector(`.react-select input[type=hidden][name$="[town]"]`);
|
|
64
|
+
if (s.kind === "text" || s.kind === "town") out[s.id] = control(s)?.value ?? null;
|
|
65
|
+
else if (s.kind === "line2" || s.kind === "city")
|
|
66
|
+
out[s.id] = us === (s.kind === "city") ? control(s)?.value ?? null : null;
|
|
67
|
+
else if (s.kind === "check")
|
|
68
|
+
out[s.id] = control(s) ? (control(s).checked ? "Yes" : "No") : null;
|
|
69
|
+
else if (s.kind === "radio") {
|
|
70
|
+
const on = root(s)?.querySelector(`input[name="${s.name}"]:checked`);
|
|
71
|
+
out[s.id] = on ? text(form.querySelector(`label[for="${on.id}"]`)) : null;
|
|
72
|
+
} else if (s.kind === "react" || s.kind === "state") {
|
|
73
|
+
const hidden = s.name && root(s)?.querySelector(`input[type=hidden][name="${s.name}"]`);
|
|
74
|
+
out[s.id] = shown(s.name ? hidden?.closest(".react-select") : root(s));
|
|
75
|
+
} else if (s.kind === "date") {
|
|
76
|
+
const part = n => root(s)?.querySelector(`[name="${s.name}(${n}i)]"]`)?.value;
|
|
77
|
+
const [y, m, d] = [part(1), part(2), part(3)];
|
|
78
|
+
out[s.id] = y && m && d ? `${y}-${m.padStart(2, "0")}-${d.padStart(2, "0")}` : null;
|
|
79
|
+
} else if (s.kind === "file") {
|
|
80
|
+
const names = [...(root(s)?.querySelectorAll(".bp3-tag a.link") ?? [])];
|
|
81
|
+
out[s.id] = text(names[names.length - 1]);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}"""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class _Spec:
|
|
90
|
+
"""Where a field's control is: the component's root id (random per page load), the control's
|
|
91
|
+
name, and how it renders."""
|
|
92
|
+
|
|
93
|
+
kind: (
|
|
94
|
+
str # text | check | radio | react | date | file; the address's line2 | city | town | state
|
|
95
|
+
)
|
|
96
|
+
name: str | None = None
|
|
97
|
+
root: str | None = None
|
|
98
|
+
options: dict[str, str] | None = None # a radio's value by its label
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Pinpoint:
|
|
102
|
+
name = "pinpoint"
|
|
103
|
+
known_gaps: dict[str, Gap] = {JOB_PREFERENCES: Gap.RENDERING}
|
|
104
|
+
side_effects = [
|
|
105
|
+
SideEffect("upload", "hiring_system", "a document is uploaded to Pinpoint at once"),
|
|
106
|
+
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
def __init__(self) -> None:
|
|
110
|
+
self._fields: list[Field] = []
|
|
111
|
+
self._specs: dict[str, _Spec] = {}
|
|
112
|
+
|
|
113
|
+
def detect(self, url: str, page: Page | None) -> bool:
|
|
114
|
+
return _job(url) is not None
|
|
115
|
+
|
|
116
|
+
def posting(self, url: str) -> str:
|
|
117
|
+
job = _job(url)
|
|
118
|
+
return f"https://{urlsplit(url).hostname}/en/postings/{job[1]}" if job else url
|
|
119
|
+
|
|
120
|
+
def read_api(self, url: str) -> list[Field] | None:
|
|
121
|
+
if _job(url) is None:
|
|
122
|
+
return None
|
|
123
|
+
request = urllib.request.Request(_apply(url), headers={"User-Agent": "atsfill"})
|
|
124
|
+
try:
|
|
125
|
+
with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
|
|
126
|
+
html = response.read().decode()
|
|
127
|
+
except OSError, ValueError:
|
|
128
|
+
return None
|
|
129
|
+
form = next(
|
|
130
|
+
(n for n in _parse(html).walk() if n.attrs.get("id") == "application-form"), None
|
|
131
|
+
)
|
|
132
|
+
return [f for f, _ in _form(form, self)] if form else None
|
|
133
|
+
|
|
134
|
+
def read_form(self, page: Page) -> list[Field]:
|
|
135
|
+
if _job(page.url) and not urlsplit(page.url).path.rstrip("/").endswith("/new"):
|
|
136
|
+
page.goto(_apply(page.url))
|
|
137
|
+
try:
|
|
138
|
+
page.locator(SUBMIT).wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
|
|
139
|
+
except Timeout:
|
|
140
|
+
raise Unsupported("posting_gone: no application form on the page") from None
|
|
141
|
+
_hydrated(page)
|
|
142
|
+
listed = _form(_parse(page.locator(FORM).evaluate("f => f.outerHTML")), self)
|
|
143
|
+
self._specs = {f.id: s for f, s in listed if not f.unfillable}
|
|
144
|
+
self._fields = [f for f, _ in listed if not f.unfillable]
|
|
145
|
+
return [f for f, _ in listed]
|
|
146
|
+
|
|
147
|
+
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
148
|
+
_decline_cookies(page)
|
|
149
|
+
form = page.locator(FORM)
|
|
150
|
+
for f in self._fields:
|
|
151
|
+
if f.id not in answers:
|
|
152
|
+
continue
|
|
153
|
+
answer, spec = answers[f.id], self._specs[f.id]
|
|
154
|
+
values = [answer] if isinstance(answer, str) else answer
|
|
155
|
+
root = form.locator(f'[id="{spec.root}"]') if spec.root else form
|
|
156
|
+
if spec.root and not _shown(root):
|
|
157
|
+
continue # a question its parent's answer keeps hidden
|
|
158
|
+
control = root.locator(f'[name="{spec.name}"]:not([type=hidden])')
|
|
159
|
+
us = root.locator(US_STATE)
|
|
160
|
+
match spec.kind:
|
|
161
|
+
case "text":
|
|
162
|
+
control.fill(values[0])
|
|
163
|
+
case "line2" | "town" if not us.count():
|
|
164
|
+
control.fill(values[0])
|
|
165
|
+
case "city" if us.count():
|
|
166
|
+
control.fill(values[0])
|
|
167
|
+
case "state" if us.count():
|
|
168
|
+
_pick(us, values[0], typed=True)
|
|
169
|
+
case "check":
|
|
170
|
+
control.set_checked(values[0] == YES_NO[0])
|
|
171
|
+
case "radio":
|
|
172
|
+
value = (spec.options or {}).get(values[0], values[0])
|
|
173
|
+
radio = root.locator(f'input[name="{spec.name}"][value="{value}"]')
|
|
174
|
+
form.locator(f'label[for="{radio.get_attribute("id")}"]').click()
|
|
175
|
+
case "react":
|
|
176
|
+
box = root.locator(".react-select").first
|
|
177
|
+
if spec.name:
|
|
178
|
+
box = root.locator(f'.react-select:has(input[name="{spec.name}"])')
|
|
179
|
+
for value in values:
|
|
180
|
+
_pick(box, value)
|
|
181
|
+
if box.locator(".react-select__option").count(): # a multi-select's menu
|
|
182
|
+
box.locator("input[role=combobox]").press("Escape")
|
|
183
|
+
case "date":
|
|
184
|
+
day = date.fromisoformat(values[0])
|
|
185
|
+
for n, part in ((1, day.year), (2, day.month), (3, day.day)):
|
|
186
|
+
_choose(root.locator(f'select[name="{spec.name}({n}i)]"]'), str(part))
|
|
187
|
+
case "file":
|
|
188
|
+
_attach(root, values[0])
|
|
189
|
+
|
|
190
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
191
|
+
specs = [
|
|
192
|
+
{"id": i, "kind": s.kind, "name": s.name, "root": s.root}
|
|
193
|
+
for i, s in self._specs.items()
|
|
194
|
+
]
|
|
195
|
+
values = page.locator(FORM).evaluate(_READ_BACK, specs)
|
|
196
|
+
return {k: v for k, v in values.items() if v not in (None, "", [])}
|
|
197
|
+
|
|
198
|
+
def submit(self, page: Page) -> str | None:
|
|
199
|
+
"""Clicks submit; the evidence is the page the form's POST leads to, unless it is the
|
|
200
|
+
form again (`/applications`, a rejected answer)."""
|
|
201
|
+
page.locator(SUBMIT).click()
|
|
202
|
+
page.wait_for_url(
|
|
203
|
+
lambda u: not urlsplit(u).path.endswith("/applications/new"), timeout=SUBMIT_TIMEOUT_MS
|
|
204
|
+
)
|
|
205
|
+
if urlsplit(page.url).path.rstrip("/").endswith("/applications"):
|
|
206
|
+
raise RuntimeError("the form came back after the submit")
|
|
207
|
+
return page.url
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _job(url: str) -> tuple[str, str] | None:
|
|
211
|
+
"""The language and posting uuid, from `/<lang>/postings/<uuid>[/applications/new]`."""
|
|
212
|
+
match = JOB.match(urlsplit(url).path)
|
|
213
|
+
return (match[1], match[2].lower()) if match else None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _apply(url: str) -> str:
|
|
217
|
+
"""The posting's apply page."""
|
|
218
|
+
job = _job(url)
|
|
219
|
+
assert job is not None
|
|
220
|
+
return f"https://{urlsplit(url).hostname}/{job[0]}/postings/{job[1]}/applications/new"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _hydrated(page: Page) -> None:
|
|
224
|
+
"""Waits for React to render the components with a field; a question's may stay empty,
|
|
225
|
+
kept hidden by an earlier answer."""
|
|
226
|
+
with suppress(Timeout):
|
|
227
|
+
page.wait_for_function(
|
|
228
|
+
"""skip => [...document.querySelectorAll("#application-form script.COMPONENT")]
|
|
229
|
+
.filter(s => !s.dataset.componentName.includes("Questions")
|
|
230
|
+
&& !skip.includes(s.dataset.componentName.split("::").pop()))
|
|
231
|
+
.every(s => document.getElementById(s.dataset.domId)?.childElementCount)""".replace(
|
|
232
|
+
"COMPONENT", COMPONENT
|
|
233
|
+
),
|
|
234
|
+
arg=sorted(NOT_FIELDS),
|
|
235
|
+
timeout=HYDRATE_TIMEOUT_MS,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _decline_cookies(page: Page) -> None:
|
|
240
|
+
"""Declines an employer's OneTrust banner, whose backdrop covers the form."""
|
|
241
|
+
if not page.locator(ONETRUST).count():
|
|
242
|
+
return
|
|
243
|
+
reject = page.locator(f"{ONETRUST} #onetrust-reject-all-handler")
|
|
244
|
+
with suppress(Timeout):
|
|
245
|
+
reject.wait_for(state="visible", timeout=COOKIE_TIMEOUT_MS)
|
|
246
|
+
reject.click()
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _shown(root: Locator) -> bool:
|
|
250
|
+
"""Whether a component shows a control, waiting a moment for one an earlier answer opens."""
|
|
251
|
+
try:
|
|
252
|
+
root.locator("input, select, textarea").first.wait_for(
|
|
253
|
+
state="attached", timeout=SHOWN_TIMEOUT_MS
|
|
254
|
+
)
|
|
255
|
+
except Timeout:
|
|
256
|
+
return False
|
|
257
|
+
return True
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _pick(box: Locator, value: str, typed: bool = False) -> None:
|
|
261
|
+
"""Opens a react-select, `typed` types the answer to narrow a long list, and clicks the
|
|
262
|
+
option that is the answer."""
|
|
263
|
+
options = box.locator(".react-select__option")
|
|
264
|
+
if not options.count(): # a multi-select's menu stays open between picks
|
|
265
|
+
box.locator(".react-select__control").click()
|
|
266
|
+
if typed:
|
|
267
|
+
box.locator("input.react-select__input").press_sequentially(value)
|
|
268
|
+
words = r"\s+".join(re.escape(w) for w in value.split())
|
|
269
|
+
option = options.filter(has_text=re.compile(rf"^\s*{words}\s*$")).first
|
|
270
|
+
try:
|
|
271
|
+
option.wait_for(timeout=OPTION_TIMEOUT_MS)
|
|
272
|
+
except Timeout:
|
|
273
|
+
box.locator(".react-select__control").press("Escape")
|
|
274
|
+
raise NotTaken(f"no option {value!r}") from None
|
|
275
|
+
option.click()
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _choose(select: Locator, value: str) -> None:
|
|
279
|
+
"""Picks a date part's option, or says the select has none for it."""
|
|
280
|
+
if not select.locator(f'option[value="{value}"]').count():
|
|
281
|
+
raise NotTaken(f"no option {value!r} in {select.get_attribute('name')}")
|
|
282
|
+
select.select_option(value)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _attach(root: Locator, path: str) -> None:
|
|
286
|
+
"""Attaches a document and waits for Pinpoint's upload to show its name."""
|
|
287
|
+
name = path.rsplit("/", 1)[-1]
|
|
288
|
+
root.locator("input[type=file]").first.set_input_files(path)
|
|
289
|
+
with suppress(Timeout):
|
|
290
|
+
root.locator(".bp3-tag a.link", has_text=name).first.wait_for(timeout=UPLOAD_TIMEOUT_MS)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@dataclass
|
|
294
|
+
class _Node:
|
|
295
|
+
tag: str
|
|
296
|
+
attrs: dict[str, str]
|
|
297
|
+
parent: _Node | None = None
|
|
298
|
+
children: list[_Node | str] = field(default_factory=list)
|
|
299
|
+
|
|
300
|
+
def walk(self) -> list[_Node]:
|
|
301
|
+
out = []
|
|
302
|
+
for child in self.children:
|
|
303
|
+
if isinstance(child, _Node):
|
|
304
|
+
out += [child, *child.walk()]
|
|
305
|
+
return out
|
|
306
|
+
|
|
307
|
+
def text(self) -> str:
|
|
308
|
+
return " ".join(
|
|
309
|
+
"".join(c if isinstance(c, str) else c.text() for c in self.children).split()
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
def classes(self) -> list[str]:
|
|
313
|
+
return self.attrs.get("class", "").split()
|
|
314
|
+
|
|
315
|
+
def in_component(self) -> bool:
|
|
316
|
+
node = self.parent
|
|
317
|
+
while node is not None and "-react-component-" not in node.attrs.get("id", ""):
|
|
318
|
+
node = node.parent
|
|
319
|
+
return node is not None
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class _Tree(HTMLParser):
|
|
323
|
+
def __init__(self) -> None:
|
|
324
|
+
super().__init__(convert_charrefs=True)
|
|
325
|
+
self.root = self.open = _Node("", {})
|
|
326
|
+
|
|
327
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
328
|
+
node = _Node(tag, {k: v or "" for k, v in attrs}, self.open)
|
|
329
|
+
self.open.children.append(node)
|
|
330
|
+
if tag not in VOID:
|
|
331
|
+
self.open = node
|
|
332
|
+
|
|
333
|
+
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
334
|
+
self.open.children.append(_Node(tag, {k: v or "" for k, v in attrs}, self.open))
|
|
335
|
+
|
|
336
|
+
def handle_endtag(self, tag: str) -> None:
|
|
337
|
+
node: _Node | None = self.open
|
|
338
|
+
while node is not None and node.tag != tag:
|
|
339
|
+
node = node.parent
|
|
340
|
+
if node is not None and node.parent is not None:
|
|
341
|
+
self.open = node.parent
|
|
342
|
+
|
|
343
|
+
def handle_data(self, data: str) -> None:
|
|
344
|
+
self.open.children.append(data)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _parse(html: str) -> _Node:
|
|
348
|
+
tree = _Tree()
|
|
349
|
+
tree.feed(html)
|
|
350
|
+
return tree.root
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _key(name: str) -> str:
|
|
354
|
+
"""A control's attribute: `first_name` for `application_form[application][first_name]`."""
|
|
355
|
+
return re.findall(r"\w+", re.sub(r"\(\di\)", "", name))[-1]
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _text(label: object) -> str:
|
|
359
|
+
"""A label with its runs of whitespace as one space, trimmed."""
|
|
360
|
+
return " ".join(str(label).split())
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _accept(extensions: list[str] | None) -> list[str] | None:
|
|
364
|
+
return [f".{e}" for e in extensions] if extensions else None
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _form(form: _Node, adapter: Pinpoint) -> list[tuple[Field, _Spec]]:
|
|
368
|
+
"""Each field of the form, in its order, with where its control is."""
|
|
369
|
+
nodes = form.walk()
|
|
370
|
+
labels = {n.attrs["for"]: n.text() for n in nodes if n.tag == "label" and "for" in n.attrs}
|
|
371
|
+
out: list[tuple[Field, _Spec]] = []
|
|
372
|
+
titles: dict[int, tuple[str, dict[str, str]]] = {} # a question's label, its values' words
|
|
373
|
+
for node in nodes:
|
|
374
|
+
if node.tag == "script" and COMPONENT in node.classes():
|
|
375
|
+
props = json.loads(node.text() or "{}")
|
|
376
|
+
name = node.attrs.get("data-component-name", "")
|
|
377
|
+
out += _component(name, props, node, labels, titles, adapter)
|
|
378
|
+
elif node.tag in ("input", "select", "textarea") and not node.in_component():
|
|
379
|
+
out += _plain(node, labels, out)
|
|
380
|
+
return out
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _plain(node: _Node, labels: dict[str, str], listed: list) -> list[tuple[Field, _Spec]]:
|
|
384
|
+
"""A server-rendered control: a name, the email, the summary, a date of birth, the consent."""
|
|
385
|
+
name, kind = node.attrs.get("name", ""), node.attrs.get("type", node.tag)
|
|
386
|
+
if not name or kind in ("hidden", "submit", "button") or "g-recaptcha" in name:
|
|
387
|
+
return []
|
|
388
|
+
field_id, required = _key(name), "required" in node.attrs
|
|
389
|
+
label = labels.get(node.attrs.get("id", ""), "").rstrip(" *") or field_id
|
|
390
|
+
if re.search(r"\(\di\)\]$", name): # a date's day, month and year selects
|
|
391
|
+
if any(f.id == field_id for f, _ in listed):
|
|
392
|
+
return []
|
|
393
|
+
label = labels.get(node.attrs.get("id", "")[: -len("_3i")], label)
|
|
394
|
+
prefix = name[: name.rindex("(")] # the selects' names are `<prefix>(<n>i)]`
|
|
395
|
+
return [(Field(field_id, label, FieldType.DATE, required), _Spec("date", prefix))]
|
|
396
|
+
if kind == "checkbox":
|
|
397
|
+
return [(Field(field_id, label, FieldType.CHOICE, required, YES_NO), _Spec("check", name))]
|
|
398
|
+
if kind in ("text", "email", "textarea"):
|
|
399
|
+
length = int(node.attrs.get("maxlength", 0)) or None
|
|
400
|
+
return [
|
|
401
|
+
(
|
|
402
|
+
Field(field_id, label, FieldType.TEXT, required, max_length=length),
|
|
403
|
+
_Spec("text", name),
|
|
404
|
+
)
|
|
405
|
+
]
|
|
406
|
+
reason = f"unhandled_rendering: {field_id} is a {kind}"
|
|
407
|
+
return [(Field(field_id, label, FieldType.TEXT, required, unfillable=reason), _Spec(kind))]
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _component(
|
|
411
|
+
name: str,
|
|
412
|
+
props: dict[str, Any],
|
|
413
|
+
script: _Node,
|
|
414
|
+
labels: dict[str, str],
|
|
415
|
+
titles: dict[int, tuple[str, dict[str, str]]],
|
|
416
|
+
adapter: Pinpoint,
|
|
417
|
+
) -> list[tuple[Field, _Spec]]:
|
|
418
|
+
"""The fields a React component renders, from its props."""
|
|
419
|
+
root = script.attrs.get("data-dom-id")
|
|
420
|
+
short = name.removeprefix("Shared::Form::").removeprefix("Questions::")
|
|
421
|
+
if name.endswith("Phonenumberinput"):
|
|
422
|
+
label = labels.get(props.get("id", ""), "Phone")
|
|
423
|
+
spec = _Spec("text", props["name"], root)
|
|
424
|
+
return [
|
|
425
|
+
(Field(_key(props["name"]), label, FieldType.PHONE, bool(props.get("required"))), spec)
|
|
426
|
+
]
|
|
427
|
+
if name.endswith("::Address"):
|
|
428
|
+
return _address(props, root)
|
|
429
|
+
if name.endswith("::Filefield"):
|
|
430
|
+
label, required = _file_label(script, props)
|
|
431
|
+
accept = _accept(props.get("accept"))
|
|
432
|
+
return [
|
|
433
|
+
(
|
|
434
|
+
Field(_key(props["name"]), label, FieldType.FILE, required, accept=accept),
|
|
435
|
+
_Spec("file", props["name"], root),
|
|
436
|
+
)
|
|
437
|
+
]
|
|
438
|
+
if name.endswith("Equalitymonitoring::Tsselect"):
|
|
439
|
+
field_id = props["inputId"].removeprefix("application_form_")
|
|
440
|
+
options = [_text(o["label"]) for o in props.get("options", [])]
|
|
441
|
+
label = labels.get(props["inputId"], field_id)
|
|
442
|
+
spec = _Spec("react", None, root)
|
|
443
|
+
return [
|
|
444
|
+
(Field(field_id, label, FieldType.CHOICE, bool(props.get("required")), options), spec)
|
|
445
|
+
]
|
|
446
|
+
if name.endswith("::Jobpreferences"):
|
|
447
|
+
field_id = props.get("jobPreferenceName", JOB_PREFERENCES)
|
|
448
|
+
reason = known_gap(adapter, JOB_PREFERENCES, f"{field_id} is a further job to apply for")
|
|
449
|
+
label = f"{props.get('ordinalizedNumber', '')} preference".strip()
|
|
450
|
+
return [
|
|
451
|
+
(Field(field_id, label, FieldType.CHOICE, False, unfillable=reason), _Spec("react"))
|
|
452
|
+
]
|
|
453
|
+
details = props.get("questionDetails")
|
|
454
|
+
if not details:
|
|
455
|
+
if name.split("::")[-1] in NOT_FIELDS or "name" not in props:
|
|
456
|
+
return []
|
|
457
|
+
reason = f"unhandled_rendering: {name}"
|
|
458
|
+
return [
|
|
459
|
+
(
|
|
460
|
+
Field(_key(props["name"]), name, FieldType.TEXT, False, unfillable=reason),
|
|
461
|
+
_Spec(name),
|
|
462
|
+
)
|
|
463
|
+
]
|
|
464
|
+
qid = details["questionId"]
|
|
465
|
+
field_id = f"question_{qid}"
|
|
466
|
+
label = " ".join(
|
|
467
|
+
(details.get("title") or details.get("additionalInformation") or field_id).split()
|
|
468
|
+
)
|
|
469
|
+
required = bool(details.get("required"))
|
|
470
|
+
options = None
|
|
471
|
+
prefix = f"application_form[application][answers_attributes][{details['index']}]"
|
|
472
|
+
yes_no = props.get("translations", {})
|
|
473
|
+
if short in TEXT_QUESTIONS:
|
|
474
|
+
field_type, spec = FieldType.TEXT, _Spec("text", props["name"], root)
|
|
475
|
+
if short == "Numberinput":
|
|
476
|
+
label += " (a number)"
|
|
477
|
+
elif short == "Boolean":
|
|
478
|
+
options = [yes_no.get("yes", "Yes"), yes_no.get("no", "No")]
|
|
479
|
+
field_type = FieldType.CHOICE
|
|
480
|
+
spec = _Spec(
|
|
481
|
+
"radio",
|
|
482
|
+
f"{prefix}[boolean_answer]",
|
|
483
|
+
root,
|
|
484
|
+
dict(zip(options, ("true", "false"), strict=True)),
|
|
485
|
+
)
|
|
486
|
+
elif short in ("Singleselect", "Multiselect"):
|
|
487
|
+
options = [_text(o["label"]) for o in props.get("options", [])]
|
|
488
|
+
field_type = FieldType.CHOICE if short == "Singleselect" else FieldType.MULTI_CHOICE
|
|
489
|
+
spec = _Spec("react", None, root)
|
|
490
|
+
elif short == "Dateselect":
|
|
491
|
+
field_type, spec = FieldType.DATE, _Spec("date", f"{prefix}[date_answer", root)
|
|
492
|
+
elif short in FILE_QUESTIONS:
|
|
493
|
+
field_type, spec = FieldType.FILE, _Spec("file", None, root)
|
|
494
|
+
else:
|
|
495
|
+
reason = f"unhandled_rendering: {field_id} is a {short}"
|
|
496
|
+
titles[qid] = (label, {})
|
|
497
|
+
return [(Field(field_id, label, FieldType.TEXT, required, unfillable=reason), _Spec(short))]
|
|
498
|
+
if parent := titles.get(props.get("conditionalQuestionParentQuestionId") or -1):
|
|
499
|
+
label += _asked_when(parent, props.get("conditionalQuestionConditions") or [])
|
|
500
|
+
required = False
|
|
501
|
+
words = dict(zip(("true", "false"), options or [], strict=False)) if short == "Boolean" else {}
|
|
502
|
+
titles[qid] = (label, words)
|
|
503
|
+
accept = _accept(props.get("accept")) if field_type is FieldType.FILE else None
|
|
504
|
+
return [(Field(field_id, label, field_type, required, options, accept=accept), spec)]
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _asked_when(parent: tuple[str, dict[str, str]], conditions: list[dict]) -> str:
|
|
508
|
+
"""` (asked when "<parent>" is <value>)`, a boolean's `true` in its words."""
|
|
509
|
+
label, words = parent
|
|
510
|
+
values = [words.get(str(c["value"]), str(c["value"])) for c in conditions]
|
|
511
|
+
negated = "not " if any(c.get("operator") == "not_equals" for c in conditions) else ""
|
|
512
|
+
return f' (asked when "{label}" is {negated}{" or ".join(values)})'
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _file_label(script: _Node, props: dict) -> tuple[str, bool]:
|
|
516
|
+
"""A document's label, from the `external-form__label` block before its component."""
|
|
517
|
+
parent = script.parent
|
|
518
|
+
siblings = [c for c in parent.children if isinstance(c, _Node)] if parent else []
|
|
519
|
+
before = siblings[: siblings.index(script)] if script in siblings else []
|
|
520
|
+
block = next((n for n in reversed(before) if "external-form__label" in n.classes()), None)
|
|
521
|
+
if block is None:
|
|
522
|
+
return props.get("labelText") or _key(props["name"]), False
|
|
523
|
+
title = next((n for n in block.walk() if n.tag == "div"), block)
|
|
524
|
+
required = any("external-form__label--required" in n.classes() for n in [block, *block.walk()])
|
|
525
|
+
return title.text(), required
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _address(props: dict, root: str | None) -> list[tuple[Field, _Spec]]:
|
|
529
|
+
"""The address: a country, then lines whose labels and kinds follow it. The United States
|
|
530
|
+
asks for a city in the second line and a state (a select) in the town's place."""
|
|
531
|
+
names, words = props["fieldNames"], props.get("translations", {})
|
|
532
|
+
country = words.get("country", "Country")
|
|
533
|
+
us, not_us = (f' (asked when "{country}" is {n}{US})' for n in ("", "not "))
|
|
534
|
+
countries = [_text(o["label"]) for o in props.get("countryOptions", [])]
|
|
535
|
+
states = [_text(o["label"]) for o in props.get("stateOptions", [])]
|
|
536
|
+
line1 = f"{words.get('addressOne', 'Address Line 1')} ({words.get('streetAddress')})"
|
|
537
|
+
postcode = f"{words.get('postcode', 'Postcode')} ({words.get('zipcode')})"
|
|
538
|
+
rows = [ # id, label, type, required, options, kind, name
|
|
539
|
+
("country", country, FieldType.CHOICE, True, countries, "react", "country"),
|
|
540
|
+
("address1", line1, FieldType.TEXT, True, None, "text", "addressOne"),
|
|
541
|
+
("address2", f"{words.get('addressTwo')}{not_us}", FieldType.TEXT, False, None, "line2",
|
|
542
|
+
"addressTwo"),
|
|
543
|
+
("city", f"{words.get('city')}{us}", FieldType.TEXT, False, None, "city", "addressTwo"),
|
|
544
|
+
("town", f"{words.get('town')}{not_us}", FieldType.TEXT, False, None, "town", "town"),
|
|
545
|
+
("state", f"{words.get('state')}{us}", FieldType.CHOICE, False, states, "state", "town"),
|
|
546
|
+
("postcode", postcode, FieldType.TEXT, True, None, "text", "postcode"),
|
|
547
|
+
] # fmt: skip
|
|
548
|
+
return [
|
|
549
|
+
(Field(i, label, t, required, options), _Spec(kind, names[name], root))
|
|
550
|
+
for i, label, t, required, options, kind, name in rows
|
|
551
|
+
]
|
|
@@ -334,7 +334,9 @@ kind = "ats"
|
|
|
334
334
|
share = 0.0041
|
|
335
335
|
account = "none"
|
|
336
336
|
hosting = ["hosted"]
|
|
337
|
+
adapter = "personio"
|
|
337
338
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
339
|
+
dry_run_verified = { date = 2026-10-22, version = "0.0.3", by = "adapter_gate", commit = "ca99247" }
|
|
338
340
|
|
|
339
341
|
[gupy]
|
|
340
342
|
kind = "ats"
|
|
@@ -362,7 +364,9 @@ kind = "ats"
|
|
|
362
364
|
share = 0.0035
|
|
363
365
|
account = "none"
|
|
364
366
|
hosting = ["hosted"]
|
|
367
|
+
adapter = "pinpoint"
|
|
365
368
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
369
|
+
dry_run_verified = { date = 2026-10-22, version = "0.0.3", by = "adapter_gate", commit = "58b3972" }
|
|
366
370
|
|
|
367
371
|
[radancy]
|
|
368
372
|
kind = "aggregator"
|
|
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
|