atsfill 0.0.4.dev102__tar.gz → 0.0.4.dev106__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.dev102 → atsfill-0.0.4.dev106}/PKG-INFO +2 -2
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/README.md +1 -1
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/paylocity.py +4 -2
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/recruitee.py +129 -68
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/teamtailor.py +248 -73
- atsfill-0.0.4.dev106/src/atsfill/core/html.py +71 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/registry.toml +2 -2
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/LICENSE +0 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev102 → atsfill-0.0.4.dev106}/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.dev106
|
|
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
|
|
@@ -57,7 +57,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
57
57
|
## Supported hiring systems
|
|
58
58
|
|
|
59
59
|
<!-- badges -->
|
|
60
|
-
                                         
|
|
61
61
|
<!-- /badges -->
|
|
62
62
|
|
|
63
63
|
| System | Account needed | `read` without a browser |
|
|
@@ -29,7 +29,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
29
29
|
## Supported hiring systems
|
|
30
30
|
|
|
31
31
|
<!-- badges -->
|
|
32
|
-
                                         
|
|
33
33
|
<!-- /badges -->
|
|
34
34
|
|
|
35
35
|
| System | Account needed | `read` without a browser |
|
|
@@ -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)
|
|
@@ -7,23 +7,27 @@ field's id. Nothing reaches Recruitee before submit, so the read-back is the pag
|
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
import re
|
|
10
|
+
import urllib.request
|
|
10
11
|
from typing import Any
|
|
11
12
|
from urllib.parse import urlsplit
|
|
12
13
|
|
|
13
|
-
from atsfill.core import browser
|
|
14
|
+
from atsfill.core import browser, html
|
|
14
15
|
from atsfill.core.adapter import Blocked, Gap, Unsupported, out_of_scope
|
|
15
|
-
from atsfill.core.browser import Locator, Page, Timeout
|
|
16
|
+
from atsfill.core.browser import HTTP_TIMEOUT_S, Locator, Page, Timeout
|
|
17
|
+
from atsfill.core.html import Node
|
|
16
18
|
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
17
19
|
|
|
18
20
|
JOB = re.compile(r"^/o/([^/?#]+)")
|
|
19
21
|
CAREER_SITE = "script[src*='/js/PublicApp/'], script[src*='recruiteecdn.com']"
|
|
20
|
-
|
|
22
|
+
FORM_ID = "offer-application-form"
|
|
23
|
+
FORM = f"form#{FORM_ID}"
|
|
21
24
|
FORM_TIMEOUT_MS = 30_000
|
|
22
25
|
SUBMIT_TIMEOUT_MS = 90_000
|
|
23
26
|
KEY_DELAY_MS = 20
|
|
24
27
|
YES_NO = ["Yes", "No"]
|
|
25
28
|
LETTER_FILE, LETTER = "candidate.coverLetterFile", "candidate.coverLetter"
|
|
26
|
-
|
|
29
|
+
LETTER_SWITCH_CY = "cover-letter-switch-button"
|
|
30
|
+
LETTER_SWITCH = f"[data-cy={LETTER_SWITCH_CY}]"
|
|
27
31
|
EXTENSIONS = {"image/png": [".png"], "image/jpeg": [".jpg", ".jpeg"], "application/pdf": [".pdf"]}
|
|
28
32
|
KINDS = {
|
|
29
33
|
"text": FieldType.TEXT,
|
|
@@ -37,69 +41,6 @@ KINDS = {
|
|
|
37
41
|
"checkboxes": FieldType.MULTI_CHOICE,
|
|
38
42
|
}
|
|
39
43
|
|
|
40
|
-
# The fields in page order, one per control name: its question's label, and how it renders. A
|
|
41
|
-
# labelled question without a named control (a video recorder, drawn late) comes as kind
|
|
42
|
-
# `video`; one without a label is text only. A cover letter upload the switch turns into a
|
|
43
|
-
# textarea is followed by that textarea, its alternative.
|
|
44
|
-
_READ = """(form, [letterFile, letter, letterSwitch]) => {
|
|
45
|
-
const text = el => {
|
|
46
|
-
if (!el) return "";
|
|
47
|
-
const copy = el.cloneNode(true);
|
|
48
|
-
copy.querySelectorAll("[aria-hidden=true]").forEach(e => e.remove());
|
|
49
|
-
return copy.textContent.replace(/\\s+/g, " ").trim();
|
|
50
|
-
};
|
|
51
|
-
const found = new Map();
|
|
52
|
-
const controls = form.querySelectorAll(
|
|
53
|
-
"input[name]:not([type=hidden]), textarea[name], input[name$='.openQuestionId']");
|
|
54
|
-
for (const el of controls) {
|
|
55
|
-
if (el.type === "hidden") {
|
|
56
|
-
const question = el.name.replace(/\\.openQuestionId$/, "");
|
|
57
|
-
const label = el.parentElement.querySelector("label");
|
|
58
|
-
if (label && !form.querySelector(
|
|
59
|
-
`[name^="${CSS.escape(question)}."]:not([type=hidden])`))
|
|
60
|
-
found.set(question, {id: question, kind: "video", label: text(label),
|
|
61
|
-
required: /\\*/.test(label.textContent), options: []});
|
|
62
|
-
continue;
|
|
63
|
-
}
|
|
64
|
-
const name = el.name;
|
|
65
|
-
let field = found.get(name);
|
|
66
|
-
if (!field) {
|
|
67
|
-
const group = el.closest("fieldset");
|
|
68
|
-
field = {id: name, required: false, options: [], accept: null,
|
|
69
|
-
maxLength: el.maxLength > 0 ? el.maxLength : null};
|
|
70
|
-
if (el.type === "radio") field.kind = "radio";
|
|
71
|
-
else if (el.type === "checkbox" && /\\.(flag|consent)$/.test(name)) {
|
|
72
|
-
field.kind = "legal";
|
|
73
|
-
field.label = text(form.querySelector(`[id="${el.getAttribute("aria-labelledby")}"]`));
|
|
74
|
-
} else if (el.type === "checkbox") field.kind = "checkboxes";
|
|
75
|
-
else if (el.type === "file") {
|
|
76
|
-
field.kind = "file";
|
|
77
|
-
field.accept = el.accept ? el.accept.split(",").map(a => a.trim()) : null;
|
|
78
|
-
} else if (el.type === "tel") field.kind = "phone";
|
|
79
|
-
else if (el.type === "date") field.kind = "date";
|
|
80
|
-
else if (el.type === "number") field.kind = "number";
|
|
81
|
-
else field.kind = "text";
|
|
82
|
-
if (field.label === undefined) field.label = ["radio", "checkboxes"].includes(field.kind)
|
|
83
|
-
? text(group?.querySelector("legend"))
|
|
84
|
-
: text(form.querySelector(`label[for="${el.id}"]`)) || el.placeholder;
|
|
85
|
-
found.set(name, field);
|
|
86
|
-
}
|
|
87
|
-
field.required ||= el.required;
|
|
88
|
-
if (el.type === "radio" || el.type === "checkbox" && field.kind === "checkboxes")
|
|
89
|
-
field.options.push(text(form.querySelector(`label[for="${el.id}"]`)) || el.value);
|
|
90
|
-
}
|
|
91
|
-
const out = [];
|
|
92
|
-
for (const field of found.values()) {
|
|
93
|
-
out.push(field);
|
|
94
|
-
if (field.id === letterFile && form.querySelector(letterSwitch)) {
|
|
95
|
-
field.oneOf = letterFile;
|
|
96
|
-
out.push({id: letter, kind: "letter", label: field.label, required: field.required,
|
|
97
|
-
options: [], accept: null, maxLength: null, oneOf: letterFile});
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
return out;
|
|
101
|
-
}"""
|
|
102
|
-
|
|
103
44
|
# What each field shows: a choice its checked option's text, a file its attached name.
|
|
104
45
|
_READ_BACK = """(form, kinds) => {
|
|
105
46
|
const optionText = el => (form.querySelector(`label[for="${el.id}"]`)?.textContent || el.value)
|
|
@@ -151,8 +92,28 @@ class Recruitee:
|
|
|
151
92
|
form.wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
|
|
152
93
|
except Timeout:
|
|
153
94
|
raise Unsupported("posting_gone: no application form on the page") from None
|
|
95
|
+
return self._listed(html.parse(form.evaluate("f => f.outerHTML")))
|
|
96
|
+
|
|
97
|
+
def request_read(self, url: str) -> list[Field] | None:
|
|
98
|
+
"""The form's own page, server-rendered, on a `*.recruitee.com` host: a custom domain is
|
|
99
|
+
known only by its page."""
|
|
100
|
+
parts = urlsplit(url)
|
|
101
|
+
if not JOB.match(parts.path) or not (parts.hostname or "").endswith(".recruitee.com"):
|
|
102
|
+
return None
|
|
103
|
+
request = urllib.request.Request(
|
|
104
|
+
f"{self.posting(url)}/c/new", headers={"User-Agent": "atsfill"}
|
|
105
|
+
)
|
|
106
|
+
try:
|
|
107
|
+
with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
|
|
108
|
+
page = html.parse(response.read().decode())
|
|
109
|
+
except OSError, ValueError:
|
|
110
|
+
return None
|
|
111
|
+
form = page.find(lambda n: n.tag == "form" and n.attrs.get("id") == FORM_ID)
|
|
112
|
+
return self._listed(form) if form else None
|
|
113
|
+
|
|
114
|
+
def _listed(self, form: Node) -> list[Field]:
|
|
154
115
|
fields, self._kinds = [], {}
|
|
155
|
-
for raw in form
|
|
116
|
+
for raw in _read(form):
|
|
156
117
|
fields.append(_field(raw))
|
|
157
118
|
if raw["kind"] != "video":
|
|
158
119
|
self._kinds[raw["id"]] = raw["kind"]
|
|
@@ -239,3 +200,103 @@ def _option(page: Page, controls: Locator, value: str) -> Locator | None:
|
|
|
239
200
|
if " ".join((text or "").split()) == value:
|
|
240
201
|
return control
|
|
241
202
|
return None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _text(node: Node | None) -> str:
|
|
206
|
+
"""A node's text without what it hides from screen readers, its runs of whitespace one
|
|
207
|
+
space."""
|
|
208
|
+
if node is None:
|
|
209
|
+
return ""
|
|
210
|
+
|
|
211
|
+
def shown(n: Node) -> str:
|
|
212
|
+
return "".join(
|
|
213
|
+
c if isinstance(c, str) else shown(c)
|
|
214
|
+
for c in n.children
|
|
215
|
+
if isinstance(c, str) or c.attrs.get("aria-hidden") != "true"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
return re.sub(r"[\s\ufeff]+", " ", shown(node)).strip() # as JavaScript's `\s`
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _read(form: Node) -> list[dict[str, Any]]:
|
|
222
|
+
"""The fields in page order, one per control name: its question's label, and how it
|
|
223
|
+
renders. A labelled question without a named control (a video recorder, drawn late) comes as
|
|
224
|
+
kind `video`; one without a label is text only. A cover letter upload the switch turns into
|
|
225
|
+
a textarea is followed by that textarea, its alternative."""
|
|
226
|
+
nodes = form.walk()
|
|
227
|
+
by_id = {n.attrs["id"]: n for n in nodes if "id" in n.attrs}
|
|
228
|
+
labels: dict[str, Node] = {}
|
|
229
|
+
for n in nodes:
|
|
230
|
+
if n.tag == "label" and "for" in n.attrs:
|
|
231
|
+
labels.setdefault(n.attrs["for"], n)
|
|
232
|
+
found: dict[str, dict[str, Any]] = {}
|
|
233
|
+
for el in nodes:
|
|
234
|
+
name, kind = el.attrs.get("name"), el.attrs.get("type", "text").lower()
|
|
235
|
+
if el.tag not in ("input", "textarea") or not name:
|
|
236
|
+
continue
|
|
237
|
+
if el.tag == "textarea":
|
|
238
|
+
kind = "textarea"
|
|
239
|
+
if kind == "hidden":
|
|
240
|
+
if not name.endswith(".openQuestionId") or el.parent is None:
|
|
241
|
+
continue
|
|
242
|
+
question = name.removesuffix(".openQuestionId")
|
|
243
|
+
label = el.parent.find(lambda n: n.tag == "label")
|
|
244
|
+
answered = any(
|
|
245
|
+
n.attrs.get("name", "").startswith(f"{question}.")
|
|
246
|
+
and n.attrs.get("type") != "hidden"
|
|
247
|
+
for n in nodes
|
|
248
|
+
)
|
|
249
|
+
if label is not None and not answered:
|
|
250
|
+
found[question] = {"id": question, "kind": "video", "label": _text(label),
|
|
251
|
+
"required": "*" in label.text(), "options": []} # fmt: skip
|
|
252
|
+
continue
|
|
253
|
+
field = found.get(name)
|
|
254
|
+
if field is None:
|
|
255
|
+
max_length = int(el.attrs.get("maxlength") or 0)
|
|
256
|
+
field: dict[str, Any] = {
|
|
257
|
+
"id": name,
|
|
258
|
+
"required": False,
|
|
259
|
+
"options": [],
|
|
260
|
+
"accept": None,
|
|
261
|
+
"maxLength": max_length if max_length > 0 else None,
|
|
262
|
+
}
|
|
263
|
+
if kind == "radio":
|
|
264
|
+
field["kind"] = "radio"
|
|
265
|
+
elif kind == "checkbox" and re.search(r"\.(flag|consent)$", name):
|
|
266
|
+
field["kind"] = "legal"
|
|
267
|
+
field["label"] = _text(by_id.get(el.attrs.get("aria-labelledby", "")))
|
|
268
|
+
elif kind == "checkbox":
|
|
269
|
+
field["kind"] = "checkboxes"
|
|
270
|
+
elif kind == "file":
|
|
271
|
+
field["kind"] = "file"
|
|
272
|
+
accept = el.attrs.get("accept")
|
|
273
|
+
field["accept"] = [a.strip() for a in accept.split(",")] if accept else None
|
|
274
|
+
else:
|
|
275
|
+
field["kind"] = {"tel": "phone", "date": "date", "number": "number"}.get(
|
|
276
|
+
kind, "text"
|
|
277
|
+
)
|
|
278
|
+
if "label" not in field:
|
|
279
|
+
if field["kind"] in ("radio", "checkboxes"):
|
|
280
|
+
group = el.closest(lambda n: n.tag == "fieldset")
|
|
281
|
+
field["label"] = _text(
|
|
282
|
+
group.find(lambda n: n.tag == "legend") if group else None
|
|
283
|
+
)
|
|
284
|
+
else:
|
|
285
|
+
own = labels.get(el.attrs.get("id", ""))
|
|
286
|
+
field["label"] = _text(own) or el.attrs.get("placeholder", "")
|
|
287
|
+
found[name] = field
|
|
288
|
+
field["required"] = field["required"] or "required" in el.attrs
|
|
289
|
+
if kind == "radio" or (kind == "checkbox" and field["kind"] == "checkboxes"):
|
|
290
|
+
field["options"].append(
|
|
291
|
+
_text(labels.get(el.attrs.get("id", ""))) or el.attrs.get("value", "")
|
|
292
|
+
)
|
|
293
|
+
switch = any(n.attrs.get("data-cy") == LETTER_SWITCH_CY for n in nodes)
|
|
294
|
+
out = []
|
|
295
|
+
for field in found.values():
|
|
296
|
+
out.append(field)
|
|
297
|
+
if field["id"] == LETTER_FILE and switch:
|
|
298
|
+
field["oneOf"] = LETTER_FILE
|
|
299
|
+
out.append({"id": LETTER, "kind": "letter", "label": field["label"],
|
|
300
|
+
"required": field["required"], "options": [], "accept": None,
|
|
301
|
+
"maxLength": None, "oneOf": LETTER_FILE}) # fmt: skip
|
|
302
|
+
return out
|
|
@@ -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=..")
|
|
@@ -404,4 +373,210 @@ def _pick(question: Locator, value: str) -> None:
|
|
|
404
373
|
trigger = question.locator("button[aria-haspopup]").first
|
|
405
374
|
if trigger.get_attribute("aria-expanded") != "true":
|
|
406
375
|
browser.click(trigger)
|
|
407
|
-
|
|
376
|
+
items = question.locator("[role^=menuitem]")
|
|
377
|
+
for item in items.all(): # the text may hold a quote, which a selector would have to escape
|
|
378
|
+
if item.get_attribute("data-search-text") == value:
|
|
379
|
+
browser.click(item)
|
|
380
|
+
return
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _is_question(node: Node) -> bool:
|
|
384
|
+
"""A custom question, or a qualifying one (QUESTION)."""
|
|
385
|
+
return (
|
|
386
|
+
"question" in node.classes()
|
|
387
|
+
or node.attrs.get("data-careersite--form-target") == "qualifyingAnswers"
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _label_text(node: Node | None) -> str:
|
|
392
|
+
"""A label's text, without its required mark and its screen-reader text."""
|
|
393
|
+
if node is None:
|
|
394
|
+
return ""
|
|
395
|
+
parts = [
|
|
396
|
+
c if isinstance(c, str) else _label_text(c)
|
|
397
|
+
for c in node.children
|
|
398
|
+
if isinstance(c, str) or (c.tag != "sup" and "sr-only" not in c.classes())
|
|
399
|
+
]
|
|
400
|
+
return "".join(parts)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _plain(node: Node | None) -> str:
|
|
404
|
+
return " ".join(_label_text(node).split())
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _required(node: Node | None) -> bool:
|
|
408
|
+
return (
|
|
409
|
+
node is not None
|
|
410
|
+
and node.find(lambda n: n.tag == "sup" and "data-asterisk" in n.attrs) is not None
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _is_upload(node: Node) -> bool:
|
|
415
|
+
return UPLOAD in node.attrs.get("data-controller", "").split()
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _upload(box: Node) -> list[dict[str, Any]]:
|
|
419
|
+
"""A drop zone: its label names its field, `candidate_<name>`. Its `input[type=file]` is
|
|
420
|
+
added by the page's script, so the server's HTML holds none."""
|
|
421
|
+
label = box.find(lambda n: n.tag == "label" and n.attrs.get("for", "").startswith("candidate_"))
|
|
422
|
+
if label is None:
|
|
423
|
+
return []
|
|
424
|
+
field_id = f"candidate[{label.attrs['for'].removeprefix('candidate_')}]"
|
|
425
|
+
if NOT_ASKED.search(field_id):
|
|
426
|
+
return []
|
|
427
|
+
accept = box.attrs.get(f"data-{UPLOAD}-accepted-files-value")
|
|
428
|
+
return [{"id": field_id, "kind": "file", "label": _plain(label), "required": _required(label),
|
|
429
|
+
"options": None, "maxLength": None,
|
|
430
|
+
"accept": accept.split(",") if accept else None}] # fmt: skip
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _read(form: Node) -> list[dict[str, Any]]:
|
|
434
|
+
"""Every field of the form, described from its HTML. A custom question's id is
|
|
435
|
+
`question:<Teamtailor question id>`; the others are the input's name."""
|
|
436
|
+
nodes = form.walk()
|
|
437
|
+
labels: dict[str, Node] = {}
|
|
438
|
+
for n in nodes:
|
|
439
|
+
if n.tag == "label" and n.attrs.get("for"):
|
|
440
|
+
labels.setdefault(n.attrs["for"], n)
|
|
441
|
+
|
|
442
|
+
def label_of(el: Node) -> Node | None:
|
|
443
|
+
wrapping = el.parent.closest(lambda n: n.tag == "label") if el.parent else None
|
|
444
|
+
if wrapping is not None and wrapping.attrs.get("for", el.attrs.get("id")) == el.attrs.get(
|
|
445
|
+
"id"
|
|
446
|
+
):
|
|
447
|
+
return wrapping
|
|
448
|
+
return labels.get(el.attrs["id"]) if el.attrs.get("id") else None
|
|
449
|
+
|
|
450
|
+
def menu_item(box: Node, value: str) -> Node | None:
|
|
451
|
+
return box.find(
|
|
452
|
+
lambda n: (
|
|
453
|
+
n.attrs.get("role", "").startswith("menuitem")
|
|
454
|
+
and n.attrs.get("data-value") == value
|
|
455
|
+
)
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
def options(box: Node, inputs: list[Node]) -> list[str]:
|
|
459
|
+
out = []
|
|
460
|
+
for e in inputs:
|
|
461
|
+
value = e.attrs.get("value", "")
|
|
462
|
+
item = menu_item(box, value)
|
|
463
|
+
out.append(
|
|
464
|
+
_plain(label_of(e)) or (item.attrs.get("data-search-text") if item else "") or value
|
|
465
|
+
)
|
|
466
|
+
return out
|
|
467
|
+
|
|
468
|
+
def kind_of(el: Node) -> str:
|
|
469
|
+
return (
|
|
470
|
+
"textarea" if el.tag == "textarea" else el.attrs.get("type", "text").lower() or "text"
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
def picks(box: Node) -> list[Node]:
|
|
474
|
+
return [n for n in box.walk() if n.tag == "input" and kind_of(n) in ("radio", "checkbox")]
|
|
475
|
+
|
|
476
|
+
def disabled(el: Node) -> bool:
|
|
477
|
+
return (
|
|
478
|
+
"disabled" in el.attrs
|
|
479
|
+
or el.closest(lambda n: n.tag == "fieldset" and "disabled" in n.attrs) is not None
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
def question_id(q: Node) -> str | None:
|
|
483
|
+
hidden = q.find(
|
|
484
|
+
lambda n: n.tag == "input" and n.attrs.get("name", "").endswith("[question_id]")
|
|
485
|
+
)
|
|
486
|
+
return hidden.attrs.get("value") if hidden else None
|
|
487
|
+
|
|
488
|
+
fields: list[dict[str, Any]] = []
|
|
489
|
+
for el in nodes:
|
|
490
|
+
if _is_upload(el) and not el.closest(_is_question):
|
|
491
|
+
fields += _upload(el)
|
|
492
|
+
continue
|
|
493
|
+
if el.tag not in ("input", "textarea") or (
|
|
494
|
+
kind_of(el) == "file" and el.closest(_is_upload)
|
|
495
|
+
):
|
|
496
|
+
continue
|
|
497
|
+
name = el.attrs.get("name", "")
|
|
498
|
+
if el.closest(_is_question) or disabled(el) or any(f["id"] == name for f in fields):
|
|
499
|
+
continue
|
|
500
|
+
kind = kind_of(el)
|
|
501
|
+
if kind != "file" and re.search(r"_remote_url\]$", name): # an upload's address
|
|
502
|
+
continue
|
|
503
|
+
if name == LOCATION:
|
|
504
|
+
kind = "location"
|
|
505
|
+
elif kind == "checkbox" and name.endswith("[]"):
|
|
506
|
+
kind = "choices"
|
|
507
|
+
field_id = name
|
|
508
|
+
if kind == "file":
|
|
509
|
+
field_id = f"candidate[{el.attrs.get('id', '').removeprefix('candidate_')}]"
|
|
510
|
+
if kind not in STANDARD_KINDS or not field_id or NOT_ASKED.search(field_id):
|
|
511
|
+
continue
|
|
512
|
+
box = el.closest(lambda n: n.tag == "fieldset") if kind == "choices" else None
|
|
513
|
+
if box is not None:
|
|
514
|
+
host = box.find(lambda n: n.tag == "legend")
|
|
515
|
+
else:
|
|
516
|
+
controller = el.closest(lambda n: "data-controller" in n.attrs)
|
|
517
|
+
host = label_of(el) or (
|
|
518
|
+
controller.find(lambda n: n.tag == "label") if controller else None
|
|
519
|
+
)
|
|
520
|
+
same = [n for n in nodes if n.attrs.get("name") == field_id]
|
|
521
|
+
max_length = int(el.attrs.get("maxlength") or 0)
|
|
522
|
+
accept = el.attrs.get("accept")
|
|
523
|
+
fields.append({
|
|
524
|
+
"id": field_id, "kind": kind, "label": _plain(host), "required": _required(host),
|
|
525
|
+
"options": options(box, same) if box is not None else None,
|
|
526
|
+
"maxLength": max_length if max_length > 0 else None,
|
|
527
|
+
"accept": accept.split(",") if accept else None,
|
|
528
|
+
}) # fmt: skip
|
|
529
|
+
questions = [n for n in nodes if _is_question(n)]
|
|
530
|
+
for q in questions:
|
|
531
|
+
qualifying = "question" not in q.classes()
|
|
532
|
+
named = q.find(
|
|
533
|
+
lambda n: (
|
|
534
|
+
n.attrs.get("name", "").startswith("candidate[answers_attributes]")
|
|
535
|
+
and n.attrs.get("type") != "hidden"
|
|
536
|
+
)
|
|
537
|
+
)
|
|
538
|
+
file = q.find(lambda n: n.tag == "input" and kind_of(n) == "file")
|
|
539
|
+
name = named.attrs["name"] if named else ""
|
|
540
|
+
if "picked_video" in name:
|
|
541
|
+
kind = "video"
|
|
542
|
+
elif (file is not None or q.find(_is_upload)) and named is None:
|
|
543
|
+
kind = "file"
|
|
544
|
+
else:
|
|
545
|
+
kind = match[1] if (match := ANSWER_KIND.search(name)) else "unknown"
|
|
546
|
+
listed = options(q, picks(q))
|
|
547
|
+
if named is not None and kind_of(named) == "range":
|
|
548
|
+
step = float(named.attrs.get("step") or 1) or 1
|
|
549
|
+
value, top = float(named.attrs.get("min") or 0), float(named.attrs.get("max") or 0)
|
|
550
|
+
while value <= top:
|
|
551
|
+
listed.append(f"{value:g}")
|
|
552
|
+
value += step
|
|
553
|
+
menu = [
|
|
554
|
+
n.attrs.get("data-search-text", "")
|
|
555
|
+
for n in q.walk()
|
|
556
|
+
if n.attrs.get("role", "").startswith("menuitem")
|
|
557
|
+
]
|
|
558
|
+
shown_by = None
|
|
559
|
+
if uuid := q.attrs.get("data-question-show-if-uuid"):
|
|
560
|
+
parent = next((n for n in nodes if n.attrs.get("data-question-uuid") == uuid), None)
|
|
561
|
+
value = q.attrs.get("data-question-show-if-value")
|
|
562
|
+
pick = (
|
|
563
|
+
next((e for e in picks(parent) if e.attrs.get("value") == value), None)
|
|
564
|
+
if parent
|
|
565
|
+
else None
|
|
566
|
+
)
|
|
567
|
+
if parent is not None and pick is not None:
|
|
568
|
+
shown_by = {
|
|
569
|
+
"id": f"question:{question_id(parent)}",
|
|
570
|
+
"option": options(parent, [pick])[0],
|
|
571
|
+
}
|
|
572
|
+
# a video's accepted types are set by the page's script: none in the server's HTML
|
|
573
|
+
accept = [a for a in (file.attrs.get("accept") or "").split(",") if a] if file else None
|
|
574
|
+
fields.append({
|
|
575
|
+
"id": f"question:{question_id(q)}", "kind": kind, "question": True,
|
|
576
|
+
"qualifying": qualifying, "shownBy": shown_by,
|
|
577
|
+
"label": _plain(q.find(lambda n: n.tag in ("legend", "label"))),
|
|
578
|
+
"required": qualifying or q.attrs.get("data-question-mandatory") == "true",
|
|
579
|
+
"accept": (accept or None) if kind == "video" else None,
|
|
580
|
+
"options": listed or menu or None,
|
|
581
|
+
}) # fmt: skip
|
|
582
|
+
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"
|
|
@@ -288,7 +288,7 @@ account = "none"
|
|
|
288
288
|
hosting = ["hosted"]
|
|
289
289
|
adapter = "recruitee"
|
|
290
290
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
291
|
-
dry_run_verified = { date = 2026-10-
|
|
291
|
+
dry_run_verified = { date = 2026-10-25, version = "0.0.3", by = "adapter_gate", commit = "f47f160" }
|
|
292
292
|
|
|
293
293
|
[hrmdirect]
|
|
294
294
|
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
|