atsfill 0.0.4.dev106__tar.gz → 0.0.4.dev110__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.dev106 → atsfill-0.0.4.dev110}/PKG-INFO +1 -1
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/adapters/personio.py +21 -76
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/adapters/pinpoint.py +24 -78
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/adapters/teamtailor.py +3 -2
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/LICENSE +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/README.md +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/py.typed +0 -0
- {atsfill-0.0.4.dev106 → atsfill-0.0.4.dev110}/src/atsfill/registry.toml +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.dev110
|
|
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
|
|
@@ -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():
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|