atsfill 0.0.4.dev107__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.
Files changed (48) hide show
  1. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/PKG-INFO +1 -1
  2. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/pyproject.toml +1 -1
  3. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/pyproject.toml.orig +1 -1
  4. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/adapters/personio.py +21 -76
  5. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/adapters/pinpoint.py +24 -78
  6. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/adapters/teamtailor.py +3 -2
  7. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/LICENSE +0 -0
  8. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/README.md +0 -0
  9. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/__init__.py +0 -0
  10. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/adapters/__init__.py +0 -0
  11. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/adapters/ashby.py +0 -0
  12. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/adapters/bamboohr.py +0 -0
  13. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/adapters/breezy.py +0 -0
  14. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/adapters/dayforce.py +0 -0
  15. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/adapters/eightfold.py +0 -0
  16. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/adapters/greenhouse.py +0 -0
  17. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/adapters/hireology.py +0 -0
  18. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/adapters/hiringthing.py +0 -0
  19. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/adapters/jazzhr.py +0 -0
  20. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/adapters/lever.py +0 -0
  21. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/adapters/manatal.py +0 -0
  22. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/adapters/oracle.py +0 -0
  23. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/adapters/paycor.py +0 -0
  24. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/adapters/paylocity.py +0 -0
  25. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/adapters/phenom.py +0 -0
  26. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/adapters/recruitee.py +0 -0
  27. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/adapters/rippling.py +0 -0
  28. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/adapters/smartrecruiters.py +0 -0
  29. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/adapters/workable.py +0 -0
  30. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/adapters/workday.py +0 -0
  31. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/adapters/wttj.py +0 -0
  32. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/adapters/zohorecruit.py +0 -0
  33. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/cli.py +0 -0
  34. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/core/__init__.py +0 -0
  35. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/core/accounts.py +0 -0
  36. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/core/adapter.py +0 -0
  37. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/core/attempts.py +0 -0
  38. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/core/browser.py +0 -0
  39. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/core/commands.py +0 -0
  40. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/core/conditions.py +0 -0
  41. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/core/html.py +0 -0
  42. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/core/matching.py +0 -0
  43. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/core/passwords.py +0 -0
  44. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/core/paths.py +0 -0
  45. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/core/registry.py +0 -0
  46. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/core/results.py +0 -0
  47. {atsfill-0.0.4.dev107 → atsfill-0.0.4.dev110}/src/atsfill/py.typed +0 -0
  48. {atsfill-0.0.4.dev107 → 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.dev107
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.4.dev107"
3
+ version = "0.0.4.dev110"
4
4
  description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.4.dev107"
3
+ version = "0.0.4.dev110"
4
4
  description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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
- html = response.read().decode()
79
+ page = html.parse(response.read().decode())
82
80
  except OSError, ValueError:
83
81
  return None
84
- form = next((n for n in _parse(html).walk() if n.tag == "form"), None)
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(_parse(page.locator(FORM).evaluate("f => f.outerHTML")))
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
- @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]]:
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.ancestor("group")
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.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))
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, field, replace
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
- html = response.read().decode()
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(_parse(page.locator(FORM).evaluate("f => f.outerHTML")), self)
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(_parse(page.locator(FORM).evaluate("f => f.outerHTML")), self)
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: _Node, adapter: Pinpoint) -> list[tuple[Field, _Spec]]:
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 = {n.attrs["for"]: n.text() for n in nodes if n.tag == "label" and "for" in n.attrs}
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.in_component():
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: _Node, labels: dict[str, str], listed: list) -> list[tuple[Field, _Spec]]:
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: _Node,
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: _Node, props: dict) -> tuple[str, bool]:
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, _Node)] if parent else []
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
- if box.is_checked() != (answer == "Yes"):
242
- browser.click(page.locator(f"label[for='{box.get_attribute('id')}']"))
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