atsfill 0.0.4.dev101__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.
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/PKG-INFO +1 -1
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/adapters/paylocity.py +4 -2
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/adapters/smartrecruiters.py +13 -1
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/adapters/teamtailor.py +243 -72
- atsfill-0.0.4.dev104/src/atsfill/core/html.py +71 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/registry.toml +1 -1
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/LICENSE +0 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/README.md +0 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev101 → atsfill-0.0.4.dev104}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev101 → 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.
|
|
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
|
|
@@ -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)
|
|
@@ -429,6 +429,14 @@ def _inputs(box: Locator, entry: dict[str, Any], question: dict[str, Any]) -> Lo
|
|
|
429
429
|
return box.locator("input:not([type=checkbox]), textarea").nth(n)
|
|
430
430
|
|
|
431
431
|
|
|
432
|
+
def _select(box: Locator, entry: dict[str, Any], question: dict[str, Any]) -> Locator:
|
|
433
|
+
"""The autocomplete of one of the question's selects, which holds its own options: the
|
|
434
|
+
selects of a group (work shifts, an EEO pair) share option ids."""
|
|
435
|
+
selects = [e["id"] for e in question["fields"] if e["type"] == "select"]
|
|
436
|
+
hosts = box.locator("spl-autocomplete, spl-multiselect-autocomplete")
|
|
437
|
+
return hosts.nth(selects.index(entry["id"]))
|
|
438
|
+
|
|
439
|
+
|
|
432
440
|
def _fill_question(
|
|
433
441
|
box: Locator, question: dict[str, Any], entry: dict[str, Any], answer: Answer
|
|
434
442
|
) -> None:
|
|
@@ -448,7 +456,11 @@ def _fill_question(
|
|
|
448
456
|
continue
|
|
449
457
|
target.click()
|
|
450
458
|
target.press_sequentially(value[:20], delay=KEY_DELAY_MS)
|
|
451
|
-
option =
|
|
459
|
+
option = (
|
|
460
|
+
_select(box, entry, question)
|
|
461
|
+
.locator(f"spl-select-option[value='{option_id}']")
|
|
462
|
+
.first
|
|
463
|
+
)
|
|
452
464
|
try:
|
|
453
465
|
option.wait_for(timeout=OPTION_TIMEOUT_MS)
|
|
454
466
|
except Timeout:
|
|
@@ -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
|
-
#
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
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
|
-
|
|
246
|
-
|
|
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~='
|
|
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-
|
|
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
|
|
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
|