atsfill 0.0.1__py3-none-any.whl
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/__init__.py +0 -0
- atsfill/adapters/__init__.py +7 -0
- atsfill/adapters/ashby.py +262 -0
- atsfill/adapters/greenhouse.py +356 -0
- atsfill/adapters/teamtailor.py +269 -0
- atsfill/adapters/wttj.py +180 -0
- atsfill/cli.py +126 -0
- atsfill/core/__init__.py +13 -0
- atsfill/core/accounts.py +42 -0
- atsfill/core/adapter.py +58 -0
- atsfill/core/attempts.py +32 -0
- atsfill/core/browser.py +218 -0
- atsfill/core/commands.py +243 -0
- atsfill/core/matching.py +101 -0
- atsfill/core/paths.py +40 -0
- atsfill/core/results.py +89 -0
- atsfill/py.typed +0 -0
- atsfill-0.0.1.dist-info/METADATA +157 -0
- atsfill-0.0.1.dist-info/RECORD +22 -0
- atsfill-0.0.1.dist-info/WHEEL +4 -0
- atsfill-0.0.1.dist-info/entry_points.txt +3 -0
- atsfill-0.0.1.dist-info/licenses/LICENSE +21 -0
atsfill/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from atsfill.adapters.ashby import Ashby
|
|
2
|
+
from atsfill.adapters.greenhouse import Greenhouse
|
|
3
|
+
from atsfill.adapters.teamtailor import Teamtailor
|
|
4
|
+
from atsfill.adapters.wttj import WelcomeToTheJungle
|
|
5
|
+
from atsfill.core.adapter import Adapter
|
|
6
|
+
|
|
7
|
+
ADAPTERS: list[Adapter] = [Ashby(), Teamtailor(), Greenhouse(), WelcomeToTheJungle()]
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Ashby (`jobs.ashbyhq.com`, and career pages embedding it), as docs/adapters/ashby.md observes.
|
|
2
|
+
|
|
3
|
+
Every change posts `ApiSetFormValue`, whose response holds the form as Ashby will receive it:
|
|
4
|
+
that is the read-back, kept per form since a survey answer returns the survey alone.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
import urllib.request
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import parse_qs, urlsplit
|
|
12
|
+
|
|
13
|
+
from atsfill.core.adapter import Blocked, Unsupported
|
|
14
|
+
from atsfill.core.browser import Page, Response, Timeout
|
|
15
|
+
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
16
|
+
|
|
17
|
+
HOST = "jobs.ashbyhq.com"
|
|
18
|
+
GRAPHQL = f"https://{HOST}/api/non-user-graphql?op=ApiJobPosting"
|
|
19
|
+
# The fields of the page's own ApiJobPosting query that `read_form` uses.
|
|
20
|
+
JOB_POSTING = """query ApiJobPosting($org: String!, $job: String!) {
|
|
21
|
+
jobPosting(organizationHostedJobsPageName: $org, jobPostingId: $job) {
|
|
22
|
+
applicationForm { ...Form }
|
|
23
|
+
surveyForms { ...Form }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
fragment Form on FormRender {
|
|
27
|
+
sections { isHidden fieldEntries { field isRequired isHidden } }
|
|
28
|
+
}"""
|
|
29
|
+
EMBED_TIMEOUT_MS = 15_000
|
|
30
|
+
TYPES = {
|
|
31
|
+
"String": FieldType.TEXT,
|
|
32
|
+
"Email": FieldType.TEXT,
|
|
33
|
+
"LongText": FieldType.TEXT,
|
|
34
|
+
"Date": FieldType.DATE,
|
|
35
|
+
"Number": FieldType.TEXT,
|
|
36
|
+
"Phone": FieldType.PHONE,
|
|
37
|
+
"File": FieldType.FILE,
|
|
38
|
+
"Location": FieldType.TYPEAHEAD,
|
|
39
|
+
"ValueSelect": FieldType.CHOICE,
|
|
40
|
+
"Boolean": FieldType.CHOICE,
|
|
41
|
+
"MultiValueSelect": FieldType.MULTI_CHOICE,
|
|
42
|
+
}
|
|
43
|
+
SETTLE_S = 1.5
|
|
44
|
+
READ_BACK_TIMEOUT_S = 30
|
|
45
|
+
SUBMIT_TIMEOUT_MS = 90_000
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _hosted(url: str) -> str:
|
|
49
|
+
"""`https://jobs.ashbyhq.com/<org>/<job id>`, without the page or query after it."""
|
|
50
|
+
org, job = urlsplit(url).path.strip("/").split("/")[:2]
|
|
51
|
+
return f"https://{HOST}/{org}/{job}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _paths(render: dict[str, Any]) -> set[str]:
|
|
55
|
+
return {e["field"]["path"] for s in render["sections"] for e in s["fieldEntries"]}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _op(response: Response) -> str:
|
|
59
|
+
return response.url.partition("?op=")[2] if "/api/non-user-graphql" in response.url else ""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Ashby:
|
|
63
|
+
name = "ashby"
|
|
64
|
+
side_effects = [
|
|
65
|
+
SideEffect(
|
|
66
|
+
"fill", "hiring_system", "each answer and the resume are saved to Ashby as typed"
|
|
67
|
+
),
|
|
68
|
+
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
def __init__(self) -> None:
|
|
72
|
+
self._forms: list[dict[str, Any]] = [] # the application form, then each survey
|
|
73
|
+
self._pending = 0
|
|
74
|
+
self._last_change = 0.0
|
|
75
|
+
self._hosted: str | None = None # the jobs.ashbyhq.com posting a custom domain embeds
|
|
76
|
+
|
|
77
|
+
def detect(self, url: str, page: Page | None) -> bool:
|
|
78
|
+
"""`jobs.ashbyhq.com`, or a career page whose Ashby embed opens the `ashby_jid` job."""
|
|
79
|
+
if urlsplit(url).hostname == HOST:
|
|
80
|
+
return True
|
|
81
|
+
jid = parse_qs(urlsplit(url).query).get("ashby_jid", [None])[0]
|
|
82
|
+
if page is None or jid is None:
|
|
83
|
+
return False
|
|
84
|
+
frame = page.locator(f'iframe[src^="https://{HOST}/"][src*="/{jid}"]').first
|
|
85
|
+
try:
|
|
86
|
+
frame.wait_for(state="attached", timeout=EMBED_TIMEOUT_MS)
|
|
87
|
+
except Timeout:
|
|
88
|
+
return False
|
|
89
|
+
self._hosted = _hosted(frame.get_attribute("src") or "")
|
|
90
|
+
return True
|
|
91
|
+
|
|
92
|
+
def posting(self, url: str) -> str:
|
|
93
|
+
return _hosted(url) if urlsplit(url).hostname == HOST else self._hosted or url
|
|
94
|
+
|
|
95
|
+
def read_api(self, url: str) -> list[Field] | None:
|
|
96
|
+
"""The fields from the GraphQL query the job page itself sends, for a hosted URL."""
|
|
97
|
+
if urlsplit(url).hostname != HOST:
|
|
98
|
+
return None
|
|
99
|
+
org, job = urlsplit(_hosted(url)).path.strip("/").split("/")
|
|
100
|
+
variables = {"org": org, "job": job}
|
|
101
|
+
request = urllib.request.Request(
|
|
102
|
+
GRAPHQL,
|
|
103
|
+
json.dumps({"query": JOB_POSTING, "variables": variables}).encode(),
|
|
104
|
+
{
|
|
105
|
+
"Content-Type": "application/json",
|
|
106
|
+
"apollographql-client-name": "frontend_non_user",
|
|
107
|
+
"User-Agent": "atsfill", # Cloudflare turns away Python-urllib
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
try:
|
|
111
|
+
with urllib.request.urlopen(request, timeout=20) as response:
|
|
112
|
+
job_posting = json.load(response)["data"]["jobPosting"]
|
|
113
|
+
if not job_posting:
|
|
114
|
+
return None
|
|
115
|
+
self._forms = [job_posting["applicationForm"], *job_posting["surveyForms"]]
|
|
116
|
+
return [self._field(e) for e in self._entries()]
|
|
117
|
+
except OSError, ValueError, KeyError, TypeError, Unsupported:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
def read_form(self, page: Page) -> list[Field]:
|
|
121
|
+
page.on("request", self._on_request)
|
|
122
|
+
page.on("requestfinished", self._on_finished)
|
|
123
|
+
page.on("requestfailed", self._on_finished)
|
|
124
|
+
page.on("response", self._on_response)
|
|
125
|
+
with page.expect_response(lambda r: _op(r) == "ApiJobPosting") as posting:
|
|
126
|
+
page.goto(self.posting(page.url) + "/application")
|
|
127
|
+
job = posting.value.json()["data"]["jobPosting"]
|
|
128
|
+
if job is None:
|
|
129
|
+
raise Unsupported("posting_gone")
|
|
130
|
+
self._forms = [job["applicationForm"], *job["surveyForms"]]
|
|
131
|
+
page.wait_for_selector(".ashby-application-form-submit-button")
|
|
132
|
+
return [self._field(e) for e in self._entries()]
|
|
133
|
+
|
|
134
|
+
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
135
|
+
fields = {e["field"]["path"]: self._field(e) for e in self._entries()}
|
|
136
|
+
for field_id, answer in answers.items():
|
|
137
|
+
entry = page.locator(f'[data-field-path="{field_id}"]')
|
|
138
|
+
entry.scroll_into_view_if_needed()
|
|
139
|
+
match fields[field_id].type, answer:
|
|
140
|
+
case FieldType.TEXT | FieldType.DATE | FieldType.PHONE, str():
|
|
141
|
+
box = entry.locator("input, textarea").first
|
|
142
|
+
box.fill(answer)
|
|
143
|
+
box.blur()
|
|
144
|
+
case FieldType.CHOICE, str():
|
|
145
|
+
entry.get_by_text(answer, exact=True).click()
|
|
146
|
+
case FieldType.MULTI_CHOICE, list():
|
|
147
|
+
for value in answer:
|
|
148
|
+
entry.get_by_text(value, exact=True).click()
|
|
149
|
+
case FieldType.TYPEAHEAD, str():
|
|
150
|
+
box = entry.locator("input[role=combobox]")
|
|
151
|
+
box.fill(answer)
|
|
152
|
+
page.get_by_role("option").filter(has_text=answer).first.click()
|
|
153
|
+
case FieldType.FILE, str():
|
|
154
|
+
entry.locator("input[type=file]").set_input_files(answer)
|
|
155
|
+
case kind, _:
|
|
156
|
+
raise Unsupported(
|
|
157
|
+
f"form_shape: cannot fill {field_id}, a {kind}, with {answer!r}"
|
|
158
|
+
)
|
|
159
|
+
self._answered = list(answers)
|
|
160
|
+
|
|
161
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
162
|
+
"""Ashby's last returned form, once every answer is held and no request is in flight."""
|
|
163
|
+
deadline = time.monotonic() + READ_BACK_TIMEOUT_S
|
|
164
|
+
while time.monotonic() < deadline:
|
|
165
|
+
values = self._values()
|
|
166
|
+
quiet = self._pending == 0 and time.monotonic() - self._last_change > SETTLE_S
|
|
167
|
+
if quiet and all(values.get(i) is not None for i in self._answered):
|
|
168
|
+
break
|
|
169
|
+
page.wait_for_timeout(250)
|
|
170
|
+
return {k: v for k, v in self._values().items() if v is not None}
|
|
171
|
+
|
|
172
|
+
def submit(self, page: Page) -> str | None:
|
|
173
|
+
with page.expect_response(
|
|
174
|
+
lambda r: _op(r).startswith("ApiSubmit") and _op(r).endswith("Action"),
|
|
175
|
+
timeout=SUBMIT_TIMEOUT_MS,
|
|
176
|
+
) as submitted:
|
|
177
|
+
page.locator(".ashby-application-form-submit-button").click()
|
|
178
|
+
response = submitted.value
|
|
179
|
+
text = response.text()
|
|
180
|
+
if "RECAPTCHA_SCORE_BELOW_THRESHOLD" in text or "spam" in text.lower():
|
|
181
|
+
raise Blocked("captcha_score")
|
|
182
|
+
body = json.loads(text)
|
|
183
|
+
if body.get("errors"):
|
|
184
|
+
raise RuntimeError(f"{_op(response)} errors: {body['errors']}")
|
|
185
|
+
action = next(iter(body["data"].values()))
|
|
186
|
+
if block := (action.get("messages") or {}).get("blockMessageForCandidateHtml"):
|
|
187
|
+
raise Blocked(f"blocked_by_system: {block}")
|
|
188
|
+
outcome = action["applicationFormResult"]
|
|
189
|
+
if outcome["__typename"] == "FormSubmitSuccess":
|
|
190
|
+
return f"{_op(response)} → FormSubmitSuccess ({response.status}, {page.url})"
|
|
191
|
+
errors = outcome.get("errorMessages") or outcome.get("formErrors")
|
|
192
|
+
raise Blocked(f"form_errors: {errors}")
|
|
193
|
+
|
|
194
|
+
def _entries(self) -> list[dict[str, Any]]:
|
|
195
|
+
return [
|
|
196
|
+
e
|
|
197
|
+
for form in self._forms
|
|
198
|
+
for s in form["sections"]
|
|
199
|
+
if not s.get("isHidden")
|
|
200
|
+
for e in s["fieldEntries"]
|
|
201
|
+
if not e.get("isHidden")
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
@staticmethod
|
|
205
|
+
def _field(entry: dict[str, Any]) -> Field:
|
|
206
|
+
f = entry["field"]
|
|
207
|
+
kind = TYPES.get(f["type"])
|
|
208
|
+
if kind is None:
|
|
209
|
+
raise Unsupported(f"form_shape: {f['path']} is an Ashby {f['type']} field")
|
|
210
|
+
options = [v["label"] for v in f.get("selectableValues") or []] or None
|
|
211
|
+
if f["type"] == "Boolean":
|
|
212
|
+
options = ["Yes", "No"]
|
|
213
|
+
return Field(f["path"], f["title"], kind, bool(entry["isRequired"]), options)
|
|
214
|
+
|
|
215
|
+
def _values(self) -> dict[str, Answer | None]:
|
|
216
|
+
return {e["field"]["path"]: self._value(e) for e in self._entries()}
|
|
217
|
+
|
|
218
|
+
@staticmethod
|
|
219
|
+
def _value(entry: dict[str, Any]) -> Answer | None:
|
|
220
|
+
held = entry.get("fieldValue")
|
|
221
|
+
if not held:
|
|
222
|
+
return None
|
|
223
|
+
if held["__typename"] == "File":
|
|
224
|
+
return held["filename"]
|
|
225
|
+
if held["__typename"] == "FileList":
|
|
226
|
+
return [f["filename"] for f in held["files"]]
|
|
227
|
+
value = held.get("value")
|
|
228
|
+
labels = {v["value"]: v["label"] for v in entry["field"].get("selectableValues") or []}
|
|
229
|
+
match value:
|
|
230
|
+
case bool():
|
|
231
|
+
return "Yes" if value else "No"
|
|
232
|
+
case {"text": str(text)}:
|
|
233
|
+
return text
|
|
234
|
+
case list():
|
|
235
|
+
return [labels.get(v, str(v)) for v in value]
|
|
236
|
+
case None:
|
|
237
|
+
return None
|
|
238
|
+
case _:
|
|
239
|
+
return labels.get(value, str(value))
|
|
240
|
+
|
|
241
|
+
def _replace(self, render: dict[str, Any]) -> None:
|
|
242
|
+
"""Puts a returned render in place of the form holding the same fields: a survey
|
|
243
|
+
answer returns the survey alone, under an id of its own."""
|
|
244
|
+
paths = _paths(render)
|
|
245
|
+
for i, form in enumerate(self._forms):
|
|
246
|
+
if _paths(form) == paths:
|
|
247
|
+
self._forms[i] = render
|
|
248
|
+
|
|
249
|
+
def _on_request(self, request: Any) -> None:
|
|
250
|
+
if "/api/non-user-graphql" in request.url:
|
|
251
|
+
self._pending += 1
|
|
252
|
+
|
|
253
|
+
def _on_finished(self, request: Any) -> None:
|
|
254
|
+
if "/api/non-user-graphql" in request.url:
|
|
255
|
+
self._pending -= 1
|
|
256
|
+
self._last_change = time.monotonic()
|
|
257
|
+
|
|
258
|
+
def _on_response(self, response: Response) -> None:
|
|
259
|
+
if _op(response) == "ApiSetFormValue" and response.ok:
|
|
260
|
+
render = response.json()["data"]["setFormValue"]
|
|
261
|
+
if render:
|
|
262
|
+
self._replace(render)
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""Greenhouse (`job-boards.greenhouse.io`, `boards.greenhouse.io`, their EU hosts, and company
|
|
2
|
+
pages embedding them), as docs/adapters/greenhouse.md observes.
|
|
3
|
+
|
|
4
|
+
Nothing but an attached file reaches Greenhouse before submit, so the read-back is the page:
|
|
5
|
+
input values, the shown value of each select, the phone country's flag, the attached file's name.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import time
|
|
11
|
+
import urllib.request
|
|
12
|
+
from typing import Any
|
|
13
|
+
from urllib.parse import parse_qs, urlencode, urlsplit
|
|
14
|
+
|
|
15
|
+
from atsfill.core import browser
|
|
16
|
+
from atsfill.core.adapter import Unsupported
|
|
17
|
+
from atsfill.core.browser import Page, Timeout
|
|
18
|
+
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
19
|
+
|
|
20
|
+
HOSTS = re.compile(r"^(job-boards|boards)(\.eu)?\.greenhouse\.io$")
|
|
21
|
+
FORM = "#application-form"
|
|
22
|
+
API = "https://boards-api.greenhouse.io/v1/boards/{board}/jobs/{job}?questions=true"
|
|
23
|
+
EMBED = "https://job-boards.greenhouse.io/embed/job_app"
|
|
24
|
+
EMBED_TIMEOUT_MS = 15_000
|
|
25
|
+
OPTIONS_TIMEOUT_MS = 3_000
|
|
26
|
+
HYDRATE_TIMEOUT_MS = 15_000
|
|
27
|
+
FORM_TIMEOUT_MS = 20_000
|
|
28
|
+
SUBMIT_TIMEOUT_MS = 90_000
|
|
29
|
+
API_TYPES = {
|
|
30
|
+
"input_text": FieldType.TEXT,
|
|
31
|
+
"textarea": FieldType.TEXT,
|
|
32
|
+
"input_file": FieldType.FILE,
|
|
33
|
+
"multi_value_single_select": FieldType.CHOICE,
|
|
34
|
+
"multi_value_multi_select": FieldType.MULTI_CHOICE,
|
|
35
|
+
}
|
|
36
|
+
YES_NO = ["Yes", "No"]
|
|
37
|
+
|
|
38
|
+
# The fields in page order. A select's options are read by opening it, from Python.
|
|
39
|
+
_READ = """form => {
|
|
40
|
+
const text = el => {
|
|
41
|
+
if (!el) return "";
|
|
42
|
+
const copy = el.cloneNode(true);
|
|
43
|
+
const marks = copy.querySelectorAll("[aria-hidden=true], .required, .visually-hidden");
|
|
44
|
+
marks.forEach(e => e.remove());
|
|
45
|
+
return copy.textContent.replace(/\\s+/g, " ").trim();
|
|
46
|
+
};
|
|
47
|
+
const labelFor = el => document.querySelector(`label[for="${CSS.escape(el.id)}"]`);
|
|
48
|
+
const required = el => el.getAttribute("aria-required") === "true" || el.required;
|
|
49
|
+
const found = [];
|
|
50
|
+
for (const el of form.querySelectorAll("input, textarea")) {
|
|
51
|
+
if (!el.id || ["hidden", "search", "file", "checkbox", "radio"].includes(el.type)) continue;
|
|
52
|
+
const combo = el.getAttribute("role") === "combobox";
|
|
53
|
+
const shell = el.closest(".select");
|
|
54
|
+
const multi = combo && !!shell?.querySelector(".select__value-container--is-multi");
|
|
55
|
+
found.push([el, {id: el.id, label: text(labelFor(el)), required: required(el),
|
|
56
|
+
kind: combo ? (multi ? "multi" : "select") : el.type === "tel" ? "tel" : "text",
|
|
57
|
+
maxLength: el.maxLength > 0 ? el.maxLength : null}]);
|
|
58
|
+
}
|
|
59
|
+
for (const group of form.querySelectorAll("fieldset.checkbox")) {
|
|
60
|
+
const boxes = [...group.querySelectorAll("input[type=checkbox]")];
|
|
61
|
+
const legend = group.querySelector("legend");
|
|
62
|
+
found.push([group, {id: group.id, kind: "checkboxes", label: text(legend),
|
|
63
|
+
required: group.getAttribute("aria-required") === "true",
|
|
64
|
+
options: boxes.map(b => text(labelFor(b)))}]);
|
|
65
|
+
}
|
|
66
|
+
for (const box of form.querySelectorAll("input[type=checkbox]")) {
|
|
67
|
+
if (box.closest("fieldset.checkbox")) continue;
|
|
68
|
+
found.push([box, {id: box.name || box.id, kind: "consent", label: text(labelFor(box)),
|
|
69
|
+
required: required(box)}]);
|
|
70
|
+
}
|
|
71
|
+
for (const group of form.querySelectorAll(".file-upload")) {
|
|
72
|
+
const input = group.querySelector("input[type=file]");
|
|
73
|
+
found.push([group, {id: group.getAttribute("aria-labelledby").replace(/^upload-label-/, ""),
|
|
74
|
+
kind: "file", label: text(group.querySelector(".upload-label")),
|
|
75
|
+
required: group.getAttribute("aria-required") === "true",
|
|
76
|
+
accept: input?.accept ? input.accept.split(",") : null}]);
|
|
77
|
+
}
|
|
78
|
+
const before = (a, b) => a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING;
|
|
79
|
+
found.sort(([a], [b]) => before(a, b) ? -1 : 1);
|
|
80
|
+
return found.map(([, field]) => field);
|
|
81
|
+
}"""
|
|
82
|
+
|
|
83
|
+
# What each field shows: a select its shown value (the phone country its flag's code), a file
|
|
84
|
+
# its attached name, a checkbox group its checked labels.
|
|
85
|
+
_READ_BACK = """(form, ids) => {
|
|
86
|
+
const text = el => el ? el.textContent.replace(/\\s+/g, " ").trim() : null;
|
|
87
|
+
const out = {};
|
|
88
|
+
for (const id of ids) {
|
|
89
|
+
const el = document.getElementById(id);
|
|
90
|
+
const upload = form.querySelector(`.file-upload[aria-labelledby="upload-label-${id}"]`);
|
|
91
|
+
if (upload) out[id] = text(upload.querySelector(".file-upload__filename p"));
|
|
92
|
+
else if (el?.tagName === "FIELDSET") {
|
|
93
|
+
out[id] = [...el.querySelectorAll("input:checked")]
|
|
94
|
+
.map(b => text(document.querySelector(`label[for="${CSS.escape(b.id)}"]`)));
|
|
95
|
+
} else if (el?.getAttribute("role") === "combobox") {
|
|
96
|
+
const select = el.closest(".select");
|
|
97
|
+
const flag = select.querySelector(".select__single-value .iti__flag");
|
|
98
|
+
const multi = [...select.querySelectorAll(".select__multi-value__label")].map(text);
|
|
99
|
+
out[id] = flag ? "flag:" + [...flag.classList].find(c => /^iti__[a-z]{2}$/.test(c)).slice(5)
|
|
100
|
+
: multi.length ? multi : text(select.querySelector(".select__single-value"));
|
|
101
|
+
} else {
|
|
102
|
+
const box = form.querySelector(`input[type=checkbox][name="${id}"]`) ?? el;
|
|
103
|
+
out[id] = box?.type === "checkbox" ? (box.checked ? "Yes" : "No") : el?.value ?? null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}"""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Greenhouse:
|
|
111
|
+
name = "greenhouse"
|
|
112
|
+
side_effects = [
|
|
113
|
+
SideEffect("upload", "hiring_system", "an attached file is stored by Greenhouse at once"),
|
|
114
|
+
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
def __init__(self) -> None:
|
|
118
|
+
self._fields: dict[str, Field] = {}
|
|
119
|
+
self._kinds: dict[str, str] = {} # how the page renders each field
|
|
120
|
+
self._flags: dict[str, dict[str, str]] = {} # per select, option text by flag code
|
|
121
|
+
self._embed: str | None = None # the job_app URL a company page embeds
|
|
122
|
+
|
|
123
|
+
def detect(self, url: str, page: Page | None) -> bool:
|
|
124
|
+
"""A board URL, or a company page whose `#grnhse_iframe` embeds a job application."""
|
|
125
|
+
if _board_job(url):
|
|
126
|
+
return True
|
|
127
|
+
if page is None:
|
|
128
|
+
return False
|
|
129
|
+
frame = page.locator("iframe#grnhse_iframe").first
|
|
130
|
+
try:
|
|
131
|
+
frame.wait_for(state="attached", timeout=EMBED_TIMEOUT_MS)
|
|
132
|
+
except Timeout:
|
|
133
|
+
return False
|
|
134
|
+
src = frame.get_attribute("src") or ""
|
|
135
|
+
query = parse_qs(urlsplit(src).query)
|
|
136
|
+
if not ("for" in query and "token" in query):
|
|
137
|
+
return False
|
|
138
|
+
keep = {"for": query["for"][0], "token": query["token"][0]}
|
|
139
|
+
if gh_src := parse_qs(urlsplit(url).query).get("gh_src"):
|
|
140
|
+
keep["gh_src"] = gh_src[0]
|
|
141
|
+
self._embed = f"{EMBED}?{urlencode(keep)}"
|
|
142
|
+
return True
|
|
143
|
+
|
|
144
|
+
def posting(self, url: str) -> str:
|
|
145
|
+
board, job = _board_job(url) or _board_job(self._embed or "") or ("", url)
|
|
146
|
+
return f"https://job-boards.greenhouse.io/{board}/jobs/{job}"
|
|
147
|
+
|
|
148
|
+
def read_api(self, url: str) -> list[Field] | None:
|
|
149
|
+
"""The fields from the Job Board API, unless the page holds some the API leaves out."""
|
|
150
|
+
found = _board_job(url)
|
|
151
|
+
if found is None:
|
|
152
|
+
return None
|
|
153
|
+
try:
|
|
154
|
+
request = urllib.request.Request(
|
|
155
|
+
API.format(board=found[0], job=found[1]), headers={"User-Agent": "atsfill"}
|
|
156
|
+
)
|
|
157
|
+
with urllib.request.urlopen(request, timeout=20) as response:
|
|
158
|
+
return _api_fields(json.load(response))
|
|
159
|
+
except OSError, ValueError, KeyError, TypeError:
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
def read_form(self, page: Page) -> list[Field]:
|
|
163
|
+
if self._embed:
|
|
164
|
+
page.goto(self._embed)
|
|
165
|
+
form = page.locator(FORM)
|
|
166
|
+
try:
|
|
167
|
+
form.wait_for(timeout=FORM_TIMEOUT_MS)
|
|
168
|
+
except Timeout:
|
|
169
|
+
raise Unsupported("posting_gone: no application form on the page") from None
|
|
170
|
+
_wait_until_live(page)
|
|
171
|
+
raws = form.evaluate(_READ)
|
|
172
|
+
fields = [self._field(page, raw) for raw in raws]
|
|
173
|
+
self._fields = {f.id: f for f in fields}
|
|
174
|
+
self._kinds = {raw["id"]: raw["kind"] for raw in raws}
|
|
175
|
+
return fields
|
|
176
|
+
|
|
177
|
+
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
178
|
+
for field_id, answer in answers.items():
|
|
179
|
+
values = [answer] if isinstance(answer, str) else answer
|
|
180
|
+
match self._kinds[field_id]:
|
|
181
|
+
case "file":
|
|
182
|
+
upload = page.locator(_upload(field_id))
|
|
183
|
+
upload.locator("input[type=file]").set_input_files(values[0])
|
|
184
|
+
upload.locator(".file-upload__filename").wait_for()
|
|
185
|
+
case "consent":
|
|
186
|
+
box = page.locator(f"{FORM} input[type=checkbox][name='{field_id}']").first
|
|
187
|
+
if box.is_checked() != (answer == "Yes"):
|
|
188
|
+
browser.click(page.locator(f"label[for='{box.get_attribute('id')}']"))
|
|
189
|
+
case "checkboxes":
|
|
190
|
+
group = page.locator(f"{FORM} fieldset[id='{field_id}']")
|
|
191
|
+
for value in values:
|
|
192
|
+
browser.click(group.locator("label").filter(has_text=_exact(value)))
|
|
193
|
+
case "select" | "multi":
|
|
194
|
+
suggested = self._fields[field_id].type is FieldType.TYPEAHEAD
|
|
195
|
+
for value in values:
|
|
196
|
+
_select(page, field_id, value, suggested)
|
|
197
|
+
case _:
|
|
198
|
+
page.locator(_by_id(field_id)).fill(values[0])
|
|
199
|
+
|
|
200
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
201
|
+
values = page.locator(FORM).evaluate(_READ_BACK, list(self._fields))
|
|
202
|
+
for field_id, flags in self._flags.items():
|
|
203
|
+
shown = values.get(field_id)
|
|
204
|
+
if isinstance(shown, str) and shown.startswith("flag:"):
|
|
205
|
+
values[field_id] = flags.get(shown[5:], shown)
|
|
206
|
+
return {k: v for k, v in values.items() if v not in (None, "", [])}
|
|
207
|
+
|
|
208
|
+
def submit(self, page: Page) -> str | None:
|
|
209
|
+
browser.click(page.locator(f"{FORM} button[type=submit]"))
|
|
210
|
+
page.wait_for_url(re.compile(r"/confirmation"), timeout=SUBMIT_TIMEOUT_MS)
|
|
211
|
+
return page.url
|
|
212
|
+
|
|
213
|
+
def _field(self, page: Page, raw: dict[str, Any]) -> Field:
|
|
214
|
+
kind, options = raw["kind"], raw.get("options")
|
|
215
|
+
match kind:
|
|
216
|
+
case "text":
|
|
217
|
+
field_type = FieldType.TEXT
|
|
218
|
+
case "tel":
|
|
219
|
+
field_type = FieldType.PHONE
|
|
220
|
+
case "file":
|
|
221
|
+
field_type = FieldType.FILE
|
|
222
|
+
case "consent":
|
|
223
|
+
field_type, options = FieldType.CHOICE, YES_NO
|
|
224
|
+
case "checkboxes":
|
|
225
|
+
field_type = FieldType.MULTI_CHOICE
|
|
226
|
+
case "select" | "multi":
|
|
227
|
+
options = self._options(page, raw["id"])
|
|
228
|
+
if options is None:
|
|
229
|
+
field_type = FieldType.TYPEAHEAD
|
|
230
|
+
else:
|
|
231
|
+
field_type = FieldType.MULTI_CHOICE if kind == "multi" else FieldType.CHOICE
|
|
232
|
+
case _:
|
|
233
|
+
raise Unsupported(f"form_shape: {raw['id']} is a {kind}")
|
|
234
|
+
return Field(
|
|
235
|
+
raw["id"],
|
|
236
|
+
raw["label"],
|
|
237
|
+
field_type,
|
|
238
|
+
raw["required"],
|
|
239
|
+
options,
|
|
240
|
+
raw.get("maxLength"),
|
|
241
|
+
raw.get("accept"),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def _options(self, page: Page, field_id: str) -> list[str] | None:
|
|
245
|
+
"""A select's options, from opening it; None for one that suggests as you type."""
|
|
246
|
+
box = page.locator(_by_id(field_id))
|
|
247
|
+
box.click()
|
|
248
|
+
menu = page.locator(f"{_by_id(f'react-select-{field_id}-listbox')} [role=option]")
|
|
249
|
+
try:
|
|
250
|
+
menu.first.wait_for(timeout=OPTIONS_TIMEOUT_MS)
|
|
251
|
+
except Timeout:
|
|
252
|
+
box.press("Escape")
|
|
253
|
+
return None
|
|
254
|
+
options = [t.strip() for t in menu.all_inner_texts()]
|
|
255
|
+
flags = menu.evaluate_all(
|
|
256
|
+
"items => items.map(i => [...(i.querySelector('.iti__flag')?.classList ?? [])]"
|
|
257
|
+
".find(c => /^iti__[a-z]{2}$/.test(c))?.slice(5))"
|
|
258
|
+
)
|
|
259
|
+
if any(flags):
|
|
260
|
+
self._flags[field_id] = {f: o for f, o in zip(flags, options, strict=True) if f}
|
|
261
|
+
box.press("Escape")
|
|
262
|
+
return options
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _wait_until_live(page: Page) -> None:
|
|
266
|
+
"""Waits for React to take over the form, which it shows a second or two before: until
|
|
267
|
+
then a select does not open and late sections (a consent box) are missing."""
|
|
268
|
+
select = page.locator(f"{FORM} [role=combobox]").first
|
|
269
|
+
if not select.count():
|
|
270
|
+
return
|
|
271
|
+
listbox = page.locator("[id^='react-select-'][id$='-listbox']")
|
|
272
|
+
deadline = time.monotonic() + HYDRATE_TIMEOUT_MS / 1000
|
|
273
|
+
while time.monotonic() < deadline:
|
|
274
|
+
select.click()
|
|
275
|
+
try:
|
|
276
|
+
listbox.first.wait_for(timeout=500)
|
|
277
|
+
except Timeout:
|
|
278
|
+
continue
|
|
279
|
+
select.press("Escape")
|
|
280
|
+
return
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _board_job(url: str) -> tuple[str, str] | None:
|
|
284
|
+
"""The board and job id of a board URL: `/<board>/jobs/<id>` or `embed/job_app?for&token`."""
|
|
285
|
+
parts = urlsplit(url)
|
|
286
|
+
if not HOSTS.match(parts.hostname or ""):
|
|
287
|
+
return None
|
|
288
|
+
if match := re.match(r"^/([^/]+)/jobs/(\d+)", parts.path):
|
|
289
|
+
return match[1], match[2]
|
|
290
|
+
query = parse_qs(parts.query)
|
|
291
|
+
if "for" in query and "token" in query:
|
|
292
|
+
return query["for"][0], query["token"][0]
|
|
293
|
+
return None
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _api_fields(job: dict[str, Any]) -> list[Field] | None:
|
|
297
|
+
if job.get("education") or any(
|
|
298
|
+
c.get("requires_consent") or c.get("requires_processing_consent")
|
|
299
|
+
for c in job.get("data_compliance") or []
|
|
300
|
+
):
|
|
301
|
+
return None # the page adds education rows or consent boxes the API does not list
|
|
302
|
+
fields = []
|
|
303
|
+
for question in job["questions"] + (job.get("location_questions") or []):
|
|
304
|
+
entry = question["fields"][0] # a file's paste-in textarea comes second
|
|
305
|
+
if entry["type"] == "input_hidden":
|
|
306
|
+
continue
|
|
307
|
+
name = entry["name"]
|
|
308
|
+
field_type = API_TYPES[entry["type"]]
|
|
309
|
+
options = [v["label"] for v in entry.get("values") or []] or None
|
|
310
|
+
if name == "phone":
|
|
311
|
+
fields.append(Field("country", "Country", FieldType.CHOICE, question["required"]))
|
|
312
|
+
field_type = FieldType.PHONE
|
|
313
|
+
if name == "location":
|
|
314
|
+
name, field_type = "candidate-location", FieldType.TYPEAHEAD
|
|
315
|
+
fields.append(Field(name, question["label"], field_type, question["required"], options))
|
|
316
|
+
for section in job.get("compliance") or []:
|
|
317
|
+
for question in section["questions"]:
|
|
318
|
+
entry = question["fields"][0]
|
|
319
|
+
options = [v["label"] for v in entry.get("values") or []] or None
|
|
320
|
+
kind = API_TYPES[entry["type"]]
|
|
321
|
+
fields.append(
|
|
322
|
+
Field(entry["name"], question["label"], kind, question["required"], options)
|
|
323
|
+
)
|
|
324
|
+
for question in (job.get("demographic_questions") or {}).get("questions", []):
|
|
325
|
+
options = [o["label"] for o in question["answer_options"]]
|
|
326
|
+
kind = API_TYPES[question["type"]]
|
|
327
|
+
fields.append(
|
|
328
|
+
Field(str(question["id"]), question["label"], kind, question["required"], options)
|
|
329
|
+
)
|
|
330
|
+
return fields
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _by_id(element_id: str) -> str:
|
|
334
|
+
"""Greenhouse ids can start with a digit or hold brackets, which `#id` does not take."""
|
|
335
|
+
return f'[id="{element_id}"]'
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _upload(field_id: str) -> str:
|
|
339
|
+
return f".file-upload[aria-labelledby='upload-label-{field_id}']"
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _exact(value: str) -> re.Pattern[str]:
|
|
343
|
+
return re.compile(rf"^\s*{re.escape(value)}\s*$")
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _select(page: Page, field_id: str, value: str, suggested: bool) -> None:
|
|
347
|
+
"""Types into a react-select and picks the option: hovered then Enter, which every select
|
|
348
|
+
takes (Country saves only on Enter). A suggestion list takes its first suggestion."""
|
|
349
|
+
box = page.locator(_by_id(field_id))
|
|
350
|
+
box.click()
|
|
351
|
+
box.press_sequentially(value, delay=20)
|
|
352
|
+
menu = page.locator(f"{_by_id(f'react-select-{field_id}-listbox')} [role=option]")
|
|
353
|
+
option = menu.first if suggested else menu.filter(has_text=_exact(value)).first
|
|
354
|
+
option.wait_for()
|
|
355
|
+
option.hover()
|
|
356
|
+
box.press("Enter")
|