atsfill 0.0.4.dev102__tar.gz → 0.0.4.dev104__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.dev102 → atsfill-0.0.4.dev104}/PKG-INFO +1 -1
  2. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/pyproject.toml +1 -1
  3. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/pyproject.toml.orig +1 -1
  4. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/adapters/paylocity.py +4 -2
  5. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/adapters/teamtailor.py +243 -72
  6. atsfill-0.0.4.dev104/src/atsfill/core/html.py +71 -0
  7. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/registry.toml +1 -1
  8. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/LICENSE +0 -0
  9. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/README.md +0 -0
  10. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/__init__.py +0 -0
  11. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/adapters/__init__.py +0 -0
  12. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/adapters/ashby.py +0 -0
  13. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/adapters/bamboohr.py +0 -0
  14. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/adapters/breezy.py +0 -0
  15. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/adapters/dayforce.py +0 -0
  16. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/adapters/eightfold.py +0 -0
  17. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/adapters/greenhouse.py +0 -0
  18. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/adapters/hireology.py +0 -0
  19. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/adapters/hiringthing.py +0 -0
  20. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/adapters/jazzhr.py +0 -0
  21. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/adapters/lever.py +0 -0
  22. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/adapters/manatal.py +0 -0
  23. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/adapters/oracle.py +0 -0
  24. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/adapters/paycor.py +0 -0
  25. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/adapters/personio.py +0 -0
  26. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/adapters/phenom.py +0 -0
  27. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/adapters/pinpoint.py +0 -0
  28. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/adapters/recruitee.py +0 -0
  29. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/adapters/rippling.py +0 -0
  30. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/adapters/smartrecruiters.py +0 -0
  31. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/adapters/workable.py +0 -0
  32. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/adapters/workday.py +0 -0
  33. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/adapters/wttj.py +0 -0
  34. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/adapters/zohorecruit.py +0 -0
  35. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/cli.py +0 -0
  36. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/core/__init__.py +0 -0
  37. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/core/accounts.py +0 -0
  38. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/core/adapter.py +0 -0
  39. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/core/attempts.py +0 -0
  40. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/core/browser.py +0 -0
  41. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/core/commands.py +0 -0
  42. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/core/conditions.py +0 -0
  43. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/core/matching.py +0 -0
  44. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/core/passwords.py +0 -0
  45. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/core/paths.py +0 -0
  46. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/core/registry.py +0 -0
  47. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/src/atsfill/core/results.py +0 -0
  48. {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev104}/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.dev102
3
+ Version: 0.0.4.dev104
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.dev102"
3
+ version = "0.0.4.dev104"
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.dev102"
3
+ version = "0.0.4.dev104"
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"
@@ -264,6 +264,8 @@ SHOWN_WITH = {
264
264
  "priorFelony": "priorConviction",
265
265
  "priorMisdemeanor": "priorConviction",
266
266
  **{f"contactSupervisor{k}": "mayWeContact" for k in ("Name", "Position", "Phone", "Email")},
267
+ # the address block renders only with its line 1
268
+ **dict.fromkeys(("country", "address2", "city", "state", "postalCode"), "address"),
267
269
  }
268
270
  SECTIONS = {
269
271
  "info": INFO,
@@ -442,6 +444,8 @@ def _form(data: dict[str, Any]) -> list[tuple[Field, Control]]:
442
444
  for field in section["fields"]:
443
445
  if not field["isIncluded"]:
444
446
  continue
447
+ if SHOWN_WITH.get(field["name"], field["name"]) not in included:
448
+ continue
445
449
  specs = (vocabulary or {}).get(field["name"])
446
450
  if field["name"] == "desiredSalary":
447
451
  specs = SALARY.get(custom.get("desiredSalaryInputType"))
@@ -449,8 +453,6 @@ def _form(data: dict[str, Any]) -> list[tuple[Field, Control]]:
449
453
  if entries and field["name"] not in NOT_ANSWERS:
450
454
  out.append(_unfillable(name, field, prefix, n, vocabulary is None))
451
455
  continue
452
- if SHOWN_WITH.get(field["name"], field["name"]) not in included:
453
- continue
454
456
  for spec in specs:
455
457
  if entries or not ENTRY.search(spec.dom): # the section's own fields show
456
458
  add(spec, field, prefix, n)
@@ -9,14 +9,16 @@ video Teamtailor holds under the id the form keeps.
9
9
  import re
10
10
  import time
11
11
  import unicodedata
12
+ import urllib.request
12
13
  from dataclasses import replace
13
14
  from pathlib import PurePath
14
15
  from typing import Any
15
16
  from urllib.parse import urlsplit
16
17
 
17
- from atsfill.core import browser, matching
18
+ from atsfill.core import browser, html, matching
18
19
  from atsfill.core.adapter import Gap, NotTaken, Unsupported, known_gap
19
- from atsfill.core.browser import Locator, Page, Response
20
+ from atsfill.core.browser import HTTP_TIMEOUT_S, Locator, Page, Response
21
+ from atsfill.core.html import Node
20
22
  from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
21
23
 
22
24
  FORM = "#job-application-form"
@@ -26,6 +28,7 @@ QUESTION = ".question, [data-careersite--form-target=qualifyingAnswers]"
26
28
  LOCKED = "[data-careersite--form-target=formContentWrapper].cursor-not-allowed"
27
29
  LOCATION = "candidate[location][query]"
28
30
  VIDEO = "[data-controller~='questions--tt-video-answer']"
31
+ UPLOAD = "forms--inputs--upload" # a drop zone's controller
29
32
  CAREER_SITE = "a[href*='//www.teamtailor.com'], script[src*='teamtailor-cdn.com/assets/careersite']"
30
33
  JOB = re.compile(r"/jobs/(\d+)[^/]*")
31
34
  SUBMIT_TIMEOUT_MS = 90_000
@@ -55,73 +58,13 @@ STANDARD_TYPES = {
55
58
  }
56
59
  CHECKBOX = ["Yes", "No"]
57
60
 
58
- # Every field of the form, described from the page. A custom question's id is
59
- # `question:<Teamtailor question id>`; the others are the input's name.
60
- _READ = """(form, QUESTION) => {
61
- const label = el => {
62
- if (!el) return "";
63
- const copy = el.cloneNode(true);
64
- copy.querySelectorAll("sup, .sr-only").forEach(e => e.remove());
65
- return copy.textContent.replace(/\\s+/g, " ").trim();
66
- };
67
- const required = el => !!el?.querySelector("sup[data-asterisk]");
68
- const PICKS = "input[type=radio], input[type=checkbox]";
69
- const options = (box, inputs) => {
70
- const item = value => box.querySelector(`[role^=menuitem][data-value="${value}"]`);
71
- return [...inputs]
72
- .map(e => label(e.labels?.[0]) || item(e.value)?.dataset.searchText || e.value);
73
- };
74
- const fields = [];
75
- for (const el of form.querySelectorAll("input, textarea")) {
76
- if (el.closest(QUESTION) || el.disabled || fields.some(f => f.id === el.name)) continue;
77
- if (el.type !== "file" && /_remote_url\\]$/.test(el.name)) continue; // an upload's address
78
- const kind = el.tagName === "TEXTAREA" ? "textarea"
79
- : el.name === "candidate[location][query]" ? "location"
80
- : el.type === "checkbox" && el.name.endsWith("[]") ? "choices" : el.type;
81
- let id = el.name;
82
- if (kind === "file") id = `candidate[${el.id.replace(/^candidate_/, "")}]`;
83
- if (!["text", "email", "tel", "textarea", "checkbox", "choices", "file", "location"]
84
- .includes(kind) || !id) continue;
85
- if (/linkedin|referrer|social_image/.test(id)) continue;
86
- const box = kind === "choices" ? el.closest("fieldset") : null;
87
- const host = box ? box.querySelector("legend")
88
- : el.labels?.[0] ?? el.closest("[data-controller]")?.querySelector("label");
89
- fields.push({id, kind, label: label(host), required: required(host),
90
- options: box ? options(box, form.querySelectorAll(`[name="${id}"]`)) : null,
91
- maxLength: el.maxLength > 0 ? el.maxLength : null,
92
- accept: el.accept ? el.accept.split(",") : null});
93
- }
94
- for (const q of form.querySelectorAll(QUESTION)) {
95
- const qid = q.querySelector("input[name$='[question_id]']")?.value;
96
- const qualifying = !q.matches(".question");
97
- const named = q.querySelector("[name^='candidate[answers_attributes]']:not([type=hidden])");
98
- const kind = /picked_video/.test(named?.name) ? "video"
99
- : q.querySelector("input[type=file]") && !named ? "file"
100
- : (named?.name.match(/\\]\\[(\\w+)\\](\\[\\])?$/)?.[1] ?? "unknown");
101
- const picks = options(q, q.querySelectorAll(PICKS));
102
- if (named?.type === "range") {
103
- const step = Number(named.step) || 1;
104
- for (let v = Number(named.min); v <= Number(named.max); v += step) picks.push(String(v));
105
- }
106
- const menu = [...q.querySelectorAll("[role^=menuitem]")].map(e => e.dataset.searchText);
107
- const parent = q.dataset.questionShowIfUuid
108
- && form.querySelector(`[data-question-uuid="${q.dataset.questionShowIfUuid}"]`);
109
- const shownBy = parent && [...parent.querySelectorAll(PICKS)]
110
- .find(e => e.value === q.dataset.questionShowIfValue);
111
- const parentId = parent?.querySelector("input[name$='[question_id]']")?.value;
112
- fields.push({id: `question:${qid}`, kind, question: true, qualifying,
113
- shownBy: parent && shownBy
114
- ? {id: `question:${parentId}`, option: options(parent, [shownBy])[0]} : null,
115
- visible: q.checkVisibility(),
116
- label: label(q.querySelector("legend, label")),
117
- required: qualifying || q.dataset.questionMandatory === "true",
118
- accept: kind === "video"
119
- ? q.querySelector("input[type=file]")?.accept.split(",").filter(Boolean) : null,
120
- options: picks.length ? picks : menu.length ? menu : null});
121
- }
122
- return fields;
123
- }"""
61
+ # A standard field the form shows but no answer is asked for.
62
+ NOT_ASKED = re.compile(r"linkedin|referrer|social_image")
63
+ STANDARD_KINDS = ("text", "email", "tel", "textarea", "checkbox", "choices", "file", "location")
64
+ ANSWER_KIND = re.compile(r"\]\[(\w+)\](\[\])?$")
124
65
 
66
+ # Whether each custom or qualifying question shows, in the order `_read` lists them.
67
+ _VISIBLE = "(form, QUESTION) => [...form.querySelectorAll(QUESTION)].map(q => q.checkVisibility())"
125
68
  # What the form will send: each field's value, a choice as its option's text, a file as the
126
69
  # uploaded file's name, an address once a suggestion is picked, a video question as the id of
127
70
  # the video it holds.
@@ -211,8 +154,32 @@ class Teamtailor:
211
154
  form.wait_for()
212
155
  except browser.Timeout:
213
156
  raise Unsupported("posting_gone: no application form on the page") from None
157
+ return self._listed(form.evaluate("f => f.outerHTML"))
158
+
159
+ def request_read(self, url: str) -> list[Field] | None:
160
+ """The form's own page, on a `*.teamtailor.com` host: a custom domain is known only by
161
+ its page."""
162
+ parts = urlsplit(url)
163
+ job = JOB.search(parts.path)
164
+ if not job or not (parts.hostname or "").endswith(".teamtailor.com"):
165
+ return None
166
+ new = f"https://{parts.hostname}{parts.path[: job.end()]}/applications/new"
167
+ request = urllib.request.Request(new, headers={"User-Agent": "atsfill"})
168
+ try:
169
+ with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
170
+ if urlsplit(response.url).hostname != parts.hostname:
171
+ return None # applies on another site
172
+ page = html.parse(response.read().decode())
173
+ except OSError, ValueError:
174
+ return None
175
+ form = page.find(lambda n: n.tag == "form" and n.attrs.get("id") == FORM[1:])
176
+ return self._listed(form) if form else None
177
+
178
+ def _listed(self, form: str | html.Node) -> list[Field]:
179
+ if isinstance(form, str):
180
+ form = html.parse(form)
214
181
  fields, self._qualifying, self._follow_ups = [], set(), set()
215
- for raw in form.evaluate(_READ, QUESTION):
182
+ for raw in _read(form):
216
183
  if raw.get("qualifying"):
217
184
  self._qualifying.add(raw["id"])
218
185
  field = self._field(raw)
@@ -242,8 +209,10 @@ class Teamtailor:
242
209
  self._wait_for_uploads(form)
243
210
 
244
211
  def shown_fields(self, page: Page) -> list[str]:
245
- raws = page.locator(FORM).evaluate(_READ, QUESTION)
246
- return [raw["id"] for raw in raws if raw.get("visible", True)]
212
+ form = page.locator(FORM)
213
+ raws = _read(html.parse(form.evaluate("f => f.outerHTML")))
214
+ visible = iter(form.evaluate(_VISIBLE, QUESTION))
215
+ return [raw["id"] for raw in raws if not raw.get("question") or next(visible)]
247
216
 
248
217
  def read_back(self, page: Page) -> dict[str, Answer]:
249
218
  values = page.locator(FORM).evaluate(_READ_BACK, QUESTION)
@@ -330,7 +299,7 @@ class Teamtailor:
330
299
  return form.locator("[data-controller~='forms--inputs--location']")
331
300
  if field_id.endswith("_remote_url]"):
332
301
  dom_id = "candidate_" + field_id[len("candidate[") : -1]
333
- return form.locator("[data-controller~='forms--inputs--upload']").filter(
302
+ return form.locator(f"[data-controller~='{UPLOAD}']").filter(
334
303
  has=form.page.locator(f"#{dom_id}")
335
304
  )
336
305
  return form.locator(f"[name='{field_id}']").first.locator("xpath=..")
@@ -405,3 +374,205 @@ def _pick(question: Locator, value: str) -> None:
405
374
  if trigger.get_attribute("aria-expanded") != "true":
406
375
  browser.click(trigger)
407
376
  browser.click(question.locator(f"[role^=menuitem][data-search-text='{value}']").first)
377
+
378
+
379
+ def _is_question(node: Node) -> bool:
380
+ """A custom question, or a qualifying one (QUESTION)."""
381
+ return (
382
+ "question" in node.classes()
383
+ or node.attrs.get("data-careersite--form-target") == "qualifyingAnswers"
384
+ )
385
+
386
+
387
+ def _label_text(node: Node | None) -> str:
388
+ """A label's text, without its required mark and its screen-reader text."""
389
+ if node is None:
390
+ return ""
391
+ parts = [
392
+ c if isinstance(c, str) else _label_text(c)
393
+ for c in node.children
394
+ if isinstance(c, str) or (c.tag != "sup" and "sr-only" not in c.classes())
395
+ ]
396
+ return "".join(parts)
397
+
398
+
399
+ def _plain(node: Node | None) -> str:
400
+ return " ".join(_label_text(node).split())
401
+
402
+
403
+ def _required(node: Node | None) -> bool:
404
+ return (
405
+ node is not None
406
+ and node.find(lambda n: n.tag == "sup" and "data-asterisk" in n.attrs) is not None
407
+ )
408
+
409
+
410
+ def _is_upload(node: Node) -> bool:
411
+ return UPLOAD in node.attrs.get("data-controller", "").split()
412
+
413
+
414
+ def _upload(box: Node) -> list[dict[str, Any]]:
415
+ """A drop zone: its label names its field, `candidate_<name>`. Its `input[type=file]` is
416
+ added by the page's script, so the server's HTML holds none."""
417
+ label = box.find(lambda n: n.tag == "label" and n.attrs.get("for", "").startswith("candidate_"))
418
+ if label is None:
419
+ return []
420
+ field_id = f"candidate[{label.attrs['for'].removeprefix('candidate_')}]"
421
+ if NOT_ASKED.search(field_id):
422
+ return []
423
+ accept = box.attrs.get(f"data-{UPLOAD}-accepted-files-value")
424
+ return [{"id": field_id, "kind": "file", "label": _plain(label), "required": _required(label),
425
+ "options": None, "maxLength": None,
426
+ "accept": accept.split(",") if accept else None}] # fmt: skip
427
+
428
+
429
+ def _read(form: Node) -> list[dict[str, Any]]:
430
+ """Every field of the form, described from its HTML. A custom question's id is
431
+ `question:<Teamtailor question id>`; the others are the input's name."""
432
+ nodes = form.walk()
433
+ labels: dict[str, Node] = {}
434
+ for n in nodes:
435
+ if n.tag == "label" and n.attrs.get("for"):
436
+ labels.setdefault(n.attrs["for"], n)
437
+
438
+ def label_of(el: Node) -> Node | None:
439
+ wrapping = el.parent.closest(lambda n: n.tag == "label") if el.parent else None
440
+ if wrapping is not None and wrapping.attrs.get("for", el.attrs.get("id")) == el.attrs.get(
441
+ "id"
442
+ ):
443
+ return wrapping
444
+ return labels.get(el.attrs["id"]) if el.attrs.get("id") else None
445
+
446
+ def menu_item(box: Node, value: str) -> Node | None:
447
+ return box.find(
448
+ lambda n: (
449
+ n.attrs.get("role", "").startswith("menuitem")
450
+ and n.attrs.get("data-value") == value
451
+ )
452
+ )
453
+
454
+ def options(box: Node, inputs: list[Node]) -> list[str]:
455
+ out = []
456
+ for e in inputs:
457
+ value = e.attrs.get("value", "")
458
+ item = menu_item(box, value)
459
+ out.append(
460
+ _plain(label_of(e)) or (item.attrs.get("data-search-text") if item else "") or value
461
+ )
462
+ return out
463
+
464
+ def kind_of(el: Node) -> str:
465
+ return (
466
+ "textarea" if el.tag == "textarea" else el.attrs.get("type", "text").lower() or "text"
467
+ )
468
+
469
+ def picks(box: Node) -> list[Node]:
470
+ return [n for n in box.walk() if n.tag == "input" and kind_of(n) in ("radio", "checkbox")]
471
+
472
+ def disabled(el: Node) -> bool:
473
+ return (
474
+ "disabled" in el.attrs
475
+ or el.closest(lambda n: n.tag == "fieldset" and "disabled" in n.attrs) is not None
476
+ )
477
+
478
+ def question_id(q: Node) -> str | None:
479
+ hidden = q.find(
480
+ lambda n: n.tag == "input" and n.attrs.get("name", "").endswith("[question_id]")
481
+ )
482
+ return hidden.attrs.get("value") if hidden else None
483
+
484
+ fields: list[dict[str, Any]] = []
485
+ for el in nodes:
486
+ if _is_upload(el) and not el.closest(_is_question):
487
+ fields += _upload(el)
488
+ continue
489
+ if el.tag not in ("input", "textarea") or (
490
+ kind_of(el) == "file" and el.closest(_is_upload)
491
+ ):
492
+ continue
493
+ name = el.attrs.get("name", "")
494
+ if el.closest(_is_question) or disabled(el) or any(f["id"] == name for f in fields):
495
+ continue
496
+ kind = kind_of(el)
497
+ if kind != "file" and re.search(r"_remote_url\]$", name): # an upload's address
498
+ continue
499
+ if name == LOCATION:
500
+ kind = "location"
501
+ elif kind == "checkbox" and name.endswith("[]"):
502
+ kind = "choices"
503
+ field_id = name
504
+ if kind == "file":
505
+ field_id = f"candidate[{el.attrs.get('id', '').removeprefix('candidate_')}]"
506
+ if kind not in STANDARD_KINDS or not field_id or NOT_ASKED.search(field_id):
507
+ continue
508
+ box = el.closest(lambda n: n.tag == "fieldset") if kind == "choices" else None
509
+ if box is not None:
510
+ host = box.find(lambda n: n.tag == "legend")
511
+ else:
512
+ controller = el.closest(lambda n: "data-controller" in n.attrs)
513
+ host = label_of(el) or (
514
+ controller.find(lambda n: n.tag == "label") if controller else None
515
+ )
516
+ same = [n for n in nodes if n.attrs.get("name") == field_id]
517
+ max_length = int(el.attrs.get("maxlength") or 0)
518
+ accept = el.attrs.get("accept")
519
+ fields.append({
520
+ "id": field_id, "kind": kind, "label": _plain(host), "required": _required(host),
521
+ "options": options(box, same) if box is not None else None,
522
+ "maxLength": max_length if max_length > 0 else None,
523
+ "accept": accept.split(",") if accept else None,
524
+ }) # fmt: skip
525
+ questions = [n for n in nodes if _is_question(n)]
526
+ for q in questions:
527
+ qualifying = "question" not in q.classes()
528
+ named = q.find(
529
+ lambda n: (
530
+ n.attrs.get("name", "").startswith("candidate[answers_attributes]")
531
+ and n.attrs.get("type") != "hidden"
532
+ )
533
+ )
534
+ file = q.find(lambda n: n.tag == "input" and kind_of(n) == "file")
535
+ name = named.attrs["name"] if named else ""
536
+ if "picked_video" in name:
537
+ kind = "video"
538
+ elif (file is not None or q.find(_is_upload)) and named is None:
539
+ kind = "file"
540
+ else:
541
+ kind = match[1] if (match := ANSWER_KIND.search(name)) else "unknown"
542
+ listed = options(q, picks(q))
543
+ if named is not None and kind_of(named) == "range":
544
+ step = float(named.attrs.get("step") or 1) or 1
545
+ value, top = float(named.attrs.get("min") or 0), float(named.attrs.get("max") or 0)
546
+ while value <= top:
547
+ listed.append(f"{value:g}")
548
+ value += step
549
+ menu = [
550
+ n.attrs.get("data-search-text", "")
551
+ for n in q.walk()
552
+ if n.attrs.get("role", "").startswith("menuitem")
553
+ ]
554
+ shown_by = None
555
+ if uuid := q.attrs.get("data-question-show-if-uuid"):
556
+ parent = next((n for n in nodes if n.attrs.get("data-question-uuid") == uuid), None)
557
+ value = q.attrs.get("data-question-show-if-value")
558
+ pick = (
559
+ next((e for e in picks(parent) if e.attrs.get("value") == value), None)
560
+ if parent
561
+ else None
562
+ )
563
+ if parent is not None and pick is not None:
564
+ shown_by = {
565
+ "id": f"question:{question_id(parent)}",
566
+ "option": options(parent, [pick])[0],
567
+ }
568
+ # a video's accepted types are set by the page's script: none in the server's HTML
569
+ accept = [a for a in (file.attrs.get("accept") or "").split(",") if a] if file else None
570
+ fields.append({
571
+ "id": f"question:{question_id(q)}", "kind": kind, "question": True,
572
+ "qualifying": qualifying, "shownBy": shown_by,
573
+ "label": _plain(q.find(lambda n: n.tag in ("legend", "label"))),
574
+ "required": qualifying or q.attrs.get("data-question-mandatory") == "true",
575
+ "accept": (accept or None) if kind == "video" else None,
576
+ "options": listed or menu or None,
577
+ }) # fmt: skip
578
+ return fields
@@ -0,0 +1,71 @@
1
+ """A page's HTML as a tree, for an adapter that reads a server-rendered form by one parser in
2
+ both its request read and its browser read (ADR 0011)."""
3
+
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass, field
6
+ from html.parser import HTMLParser
7
+
8
+ VOID = {"input", "br", "img", "hr", "meta", "link", "source", "wbr", "col", "area"}
9
+
10
+
11
+ @dataclass(eq=False)
12
+ class Node:
13
+ tag: str
14
+ attrs: dict[str, str]
15
+ parent: Node | None = None
16
+ children: list[Node | str] = field(default_factory=list)
17
+
18
+ def walk(self) -> list[Node]:
19
+ """Every element under this one, in document order."""
20
+ out = []
21
+ for child in self.children:
22
+ if isinstance(child, Node):
23
+ out += [child, *child.walk()]
24
+ return out
25
+
26
+ def find(self, match: Callable[[Node], bool]) -> Node | None:
27
+ return next((n for n in self.walk() if match(n)), None)
28
+
29
+ def closest(self, match: Callable[[Node], bool]) -> Node | None:
30
+ """This element or its nearest ancestor that matches."""
31
+ node: Node | None = self
32
+ while node is not None and not match(node):
33
+ node = node.parent
34
+ return node
35
+
36
+ def text(self) -> str:
37
+ return "".join(c if isinstance(c, str) else c.text() for c in self.children)
38
+
39
+ def classes(self) -> list[str]:
40
+ return self.attrs.get("class", "").split()
41
+
42
+
43
+ class _Tree(HTMLParser):
44
+ def __init__(self) -> None:
45
+ super().__init__(convert_charrefs=True)
46
+ self.root = self.open = Node("", {})
47
+
48
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
49
+ node = Node(tag, {k: v or "" for k, v in attrs}, self.open)
50
+ self.open.children.append(node)
51
+ if tag not in VOID:
52
+ self.open = node
53
+
54
+ def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
55
+ self.open.children.append(Node(tag, {k: v or "" for k, v in attrs}, self.open))
56
+
57
+ def handle_endtag(self, tag: str) -> None:
58
+ node: Node | None = self.open
59
+ while node is not None and node.tag != tag:
60
+ node = node.parent
61
+ if node is not None and node.parent is not None:
62
+ self.open = node.parent
63
+
64
+ def handle_data(self, data: str) -> None:
65
+ self.open.children.append(data)
66
+
67
+
68
+ def parse(html: str) -> Node:
69
+ tree = _Tree()
70
+ tree.feed(html)
71
+ return tree.root
@@ -36,7 +36,7 @@ account = "none"
36
36
  hosting = ["hosted"]
37
37
  adapter = "teamtailor"
38
38
  send_verified = { date = 2026-10-20, version = "0.0.3", by = "shbernal", commit = "9bc6d84" }
39
- dry_run_verified = { date = 2026-10-19, version = "0.0.2", by = "adapter_gate", commit = "91a0bba" }
39
+ dry_run_verified = { date = 2026-10-25, version = "0.0.3", by = "adapter_gate", commit = "6852a03" }
40
40
 
41
41
  [greenhouse]
42
42
  kind = "ats"
File without changes
File without changes