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 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")