atsfill 0.0.4.dev79__tar.gz → 0.0.4.dev80__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.dev79 → atsfill-0.0.4.dev80}/PKG-INFO +2 -2
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/README.md +1 -1
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/__init__.py +2 -0
- atsfill-0.0.4.dev80/src/atsfill/adapters/hiringthing.py +335 -0
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/registry.toml +2 -0
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/LICENSE +0 -0
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev79 → atsfill-0.0.4.dev80}/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.dev80
|
|
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
|
|
@@ -54,7 +54,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
54
54
|
## Supported hiring systems
|
|
55
55
|
|
|
56
56
|
<!-- badges -->
|
|
57
|
-
                      
|
|
57
|
+
                       
|
|
58
58
|
<!-- /badges -->
|
|
59
59
|
|
|
60
60
|
| 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
|
-
                      
|
|
32
|
+
                       
|
|
33
33
|
<!-- /badges -->
|
|
34
34
|
|
|
35
35
|
| System | Account needed | `read` without a browser |
|
|
@@ -5,6 +5,7 @@ from atsfill.adapters.dayforce import Dayforce
|
|
|
5
5
|
from atsfill.adapters.eightfold import Eightfold
|
|
6
6
|
from atsfill.adapters.greenhouse import Greenhouse
|
|
7
7
|
from atsfill.adapters.hireology import Hireology
|
|
8
|
+
from atsfill.adapters.hiringthing import HiringThing
|
|
8
9
|
from atsfill.adapters.jazzhr import JazzHR
|
|
9
10
|
from atsfill.adapters.lever import Lever
|
|
10
11
|
from atsfill.adapters.manatal import Manatal
|
|
@@ -48,6 +49,7 @@ def adapters() -> list[Adapter]:
|
|
|
48
49
|
Personio(),
|
|
49
50
|
Rippling(),
|
|
50
51
|
Pinpoint(),
|
|
52
|
+
HiringThing(),
|
|
51
53
|
Eightfold(),
|
|
52
54
|
Phenom(), # last: it claims a URL by its path alone
|
|
53
55
|
]
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""HiringThing (`<employer>.<white-label domain>/job/<id>/<slug>`), as docs/adapters/hiringthing.md
|
|
2
|
+
observes.
|
|
3
|
+
|
|
4
|
+
The job page carries the posting's configuration in a React component's props, and its Apply
|
|
5
|
+
button renders the form from them in the page: MUI inputs named by field id, radios and boxes,
|
|
6
|
+
react-select comboboxes for the country and state, Dropzone areas that upload a file as soon as
|
|
7
|
+
it is attached. `read_form` lists the rendered controls; there is no `read_api`, since which
|
|
8
|
+
controls render is the page's logic over the props, not the props alone.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from contextlib import suppress
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from urllib.parse import urlsplit
|
|
16
|
+
|
|
17
|
+
from atsfill.core.adapter import Blocked, Gap, NotTaken, Unsupported
|
|
18
|
+
from atsfill.core.browser import Locator, Page, Timeout
|
|
19
|
+
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
20
|
+
|
|
21
|
+
# The white-label domains HiringThing serves its career sites on, as freehire indexes them.
|
|
22
|
+
DOMAINS = (
|
|
23
|
+
"hiringthing.com",
|
|
24
|
+
"oasisrecruit.com",
|
|
25
|
+
"prismhr-hire.com",
|
|
26
|
+
"gnahiring.com",
|
|
27
|
+
"primepay-recruit.com",
|
|
28
|
+
"checkwritersrecruit.com",
|
|
29
|
+
"exponentats.com",
|
|
30
|
+
"rippling-ats.com",
|
|
31
|
+
"verahr-hiring.com",
|
|
32
|
+
"alphastaff-hiring.com",
|
|
33
|
+
"elevate-ats.com",
|
|
34
|
+
"iconnecthire.com",
|
|
35
|
+
"atsmodule.com",
|
|
36
|
+
"viewpointhr-ats.com",
|
|
37
|
+
"ezhiregov.com",
|
|
38
|
+
"lumberhiring.com",
|
|
39
|
+
"topdoghrrecruiting.com",
|
|
40
|
+
"teammemberhire-recruit.com",
|
|
41
|
+
"edistoats.com",
|
|
42
|
+
"topgradinghire.com",
|
|
43
|
+
)
|
|
44
|
+
JOB = re.compile(r"^/job/(\d+)(?:/|$)")
|
|
45
|
+
PROPS = "[data-react-class='HiringThing.Components.ApplyButtonGroup']"
|
|
46
|
+
APPLY = "button.job-apply-button"
|
|
47
|
+
FORM = "#job-application-form"
|
|
48
|
+
SUBMIT = f"{FORM} button[type=submit]"
|
|
49
|
+
BANNER = ".cookie-banner"
|
|
50
|
+
UPLOAD = "/client_api/add_file"
|
|
51
|
+
FORM_TIMEOUT_MS = 30_000
|
|
52
|
+
PAGE_TIMEOUT_MS = 30_000
|
|
53
|
+
MENU_TIMEOUT_MS = 5_000
|
|
54
|
+
REGIONS_TIMEOUT_MS = 10_000
|
|
55
|
+
UPLOAD_TIMEOUT_MS = 30_000
|
|
56
|
+
SUBMIT_TIMEOUT_MS = 90_000
|
|
57
|
+
REQUIRED = re.compile(r"\s*\(required\)\s*$")
|
|
58
|
+
DATE = "%m/%d/%Y"
|
|
59
|
+
YES_NO = ["Yes", "No"]
|
|
60
|
+
TYPES = {
|
|
61
|
+
"text": FieldType.TEXT,
|
|
62
|
+
"date": FieldType.DATE,
|
|
63
|
+
"phone": FieldType.PHONE,
|
|
64
|
+
"radio": FieldType.CHOICE,
|
|
65
|
+
"box": FieldType.CHOICE,
|
|
66
|
+
"boxes": FieldType.MULTI_CHOICE,
|
|
67
|
+
"select": FieldType.CHOICE,
|
|
68
|
+
"region": FieldType.TYPEAHEAD,
|
|
69
|
+
"file": FieldType.FILE,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
# Each control of the form by its name: its kind, label, options; a Dropzone area by its id.
|
|
73
|
+
_CONTROLS = """form => {
|
|
74
|
+
const text = e => (e?.innerText ?? "").replace(/\\s+/g, " ").trim();
|
|
75
|
+
const option = e => text(e.closest(".multi-option") ?? e.closest("label"));
|
|
76
|
+
const labelOf = id => document.getElementById(`${id}_label`);
|
|
77
|
+
const TEXTS = {text: "text", email: "text", textarea: "text", tel: "phone"};
|
|
78
|
+
const out = new Map();
|
|
79
|
+
for (const el of form.querySelectorAll("input, textarea, select")) {
|
|
80
|
+
const name = el.getAttribute("name") || "";
|
|
81
|
+
if (el.getAttribute("role") === "combobox") {
|
|
82
|
+
const id = el.id;
|
|
83
|
+
const kind = id.endsWith(".state") ? "region" : "select";
|
|
84
|
+
out.set(id, {id, kind, label: text(labelOf(id))});
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (!name || el.type === "hidden" || el.type === "file" || name.endsWith("phoneCountry"))
|
|
88
|
+
continue;
|
|
89
|
+
const label = labelOf(name);
|
|
90
|
+
if (el.type === "radio" || el.type === "checkbox") {
|
|
91
|
+
const own = el.type === "radio" ? el.value : option(el);
|
|
92
|
+
const entry = out.get(name) ?? {id: name, kind: el.type, label: text(label), options: []};
|
|
93
|
+
entry.options.push(own);
|
|
94
|
+
if (el.type === "checkbox") {
|
|
95
|
+
entry.kind = label?.classList.contains("multi-field-label") ? "boxes" : "box";
|
|
96
|
+
if (entry.kind === "box") entry.label = own;
|
|
97
|
+
}
|
|
98
|
+
out.set(name, entry);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const typed = el.tagName === "INPUT" ? el.type : el.tagName.toLowerCase();
|
|
102
|
+
let kind = TEXTS[typed] ?? "unhandled";
|
|
103
|
+
if (label?.classList.contains("datePicker-field-label")) kind = "date";
|
|
104
|
+
const maxLength = el.maxLength > 0 ? el.maxLength : null;
|
|
105
|
+
out.set(name, {id: name, kind, label: text(label), maxLength, typed});
|
|
106
|
+
}
|
|
107
|
+
for (const zone of form.querySelectorAll(".file-field-input[id]"))
|
|
108
|
+
out.set(zone.id, {id: zone.id, kind: "file", label: text(labelOf(`${zone.id}.file`))});
|
|
109
|
+
return [...out.values()];
|
|
110
|
+
}"""
|
|
111
|
+
|
|
112
|
+
# What each field holds: a value, the checked option, a combobox's shown value, a file's name.
|
|
113
|
+
_READ_BACK = """(form, fields) => {
|
|
114
|
+
const text = e => (e?.innerText ?? "").replace(/\\s+/g, " ").trim();
|
|
115
|
+
const option = e => text(e.closest(".multi-option") ?? e.closest("label"));
|
|
116
|
+
const out = {};
|
|
117
|
+
for (const [id, kind] of fields) {
|
|
118
|
+
const named = [...form.querySelectorAll(`[name="${id}"]`)];
|
|
119
|
+
if (kind === "radio") out[id] = named.find(e => e.checked)?.value ?? null;
|
|
120
|
+
else if (kind === "boxes")
|
|
121
|
+
out[id] = named.filter(e => e.checked).map(option);
|
|
122
|
+
else if (kind === "box") out[id] = named[0]?.checked ? "Yes" : "No";
|
|
123
|
+
else if (kind === "select" || kind === "region") {
|
|
124
|
+
const select = document.getElementById(id)?.closest(".Select");
|
|
125
|
+
out[id] = text(select?.querySelector(".Select-value-label")) || null;
|
|
126
|
+
} else if (kind === "file") {
|
|
127
|
+
const zone = document.getElementById(id);
|
|
128
|
+
const names = [...(zone?.querySelectorAll(".dz-complete .dz-filename") ?? [])];
|
|
129
|
+
out[id] = names.length ? text(names[names.length - 1]) : null;
|
|
130
|
+
} else out[id] = named[0]?.value ?? null;
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}"""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class HiringThing:
|
|
137
|
+
name = "hiringthing"
|
|
138
|
+
known_gaps: dict[str, Gap] = {}
|
|
139
|
+
side_effects = [
|
|
140
|
+
SideEffect("upload", "hiring_system", "a file is uploaded to HiringThing once attached"),
|
|
141
|
+
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
def __init__(self) -> None:
|
|
145
|
+
self._kinds: dict[str, str] = {}
|
|
146
|
+
|
|
147
|
+
def detect(self, url: str, page: Page | None) -> bool:
|
|
148
|
+
parts = urlsplit(url)
|
|
149
|
+
if not JOB.match(parts.path):
|
|
150
|
+
return False
|
|
151
|
+
host = parts.hostname or ""
|
|
152
|
+
if any(host.endswith(f".{d}") for d in DOMAINS):
|
|
153
|
+
return True
|
|
154
|
+
return page is not None and page.locator(PROPS).count() > 0
|
|
155
|
+
|
|
156
|
+
def posting(self, url: str) -> str:
|
|
157
|
+
parts = urlsplit(url)
|
|
158
|
+
job = JOB.match(parts.path)
|
|
159
|
+
return f"https://{parts.hostname}/job/{job[1]}" if job else url
|
|
160
|
+
|
|
161
|
+
def read_form(self, page: Page) -> list[Field]:
|
|
162
|
+
props = page.locator(PROPS).first
|
|
163
|
+
try:
|
|
164
|
+
props.wait_for(state="attached", timeout=PAGE_TIMEOUT_MS)
|
|
165
|
+
except Timeout:
|
|
166
|
+
raise Unsupported("posting_gone: no application on the page") from None
|
|
167
|
+
_hide_banner(page)
|
|
168
|
+
form = page.locator(FORM)
|
|
169
|
+
if not form.count():
|
|
170
|
+
page.locator(APPLY).filter(visible=True).first.click()
|
|
171
|
+
try:
|
|
172
|
+
page.locator(SUBMIT).wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
|
|
173
|
+
except Timeout:
|
|
174
|
+
raise Unsupported("posting_gone: Apply showed no form") from None
|
|
175
|
+
if form.locator("[id='user.state']:not([role=combobox])").count():
|
|
176
|
+
with suppress(Timeout): # the state list loads after the form, and replaces its input
|
|
177
|
+
_combobox(page, "user.state").wait_for(state="attached", timeout=REGIONS_TIMEOUT_MS)
|
|
178
|
+
questions = _questions(props.get_attribute("data-react-props") or "{}")
|
|
179
|
+
controls = form.evaluate(_CONTROLS)
|
|
180
|
+
self._kinds = {c["id"]: c["kind"] for c in controls if c["kind"] != "unhandled"}
|
|
181
|
+
return [
|
|
182
|
+
_field(c, questions, _menu(page, c["id"]) if c["kind"] == "select" else None)
|
|
183
|
+
for c in controls
|
|
184
|
+
]
|
|
185
|
+
|
|
186
|
+
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
187
|
+
_hide_banner(page)
|
|
188
|
+
form = page.locator(FORM)
|
|
189
|
+
# the state last: its options are the chosen country's
|
|
190
|
+
for field_id, kind in sorted(self._kinds.items(), key=lambda k: k[1] == "region"):
|
|
191
|
+
if field_id not in answers:
|
|
192
|
+
continue
|
|
193
|
+
answer = answers[field_id]
|
|
194
|
+
value = answer if isinstance(answer, str) else answer[0]
|
|
195
|
+
named = form.locator(f"[name='{field_id}']")
|
|
196
|
+
match kind:
|
|
197
|
+
case "text" | "phone":
|
|
198
|
+
named.fill(value)
|
|
199
|
+
case "date":
|
|
200
|
+
named.fill("")
|
|
201
|
+
named.press_sequentially(datetime.fromisoformat(value).strftime(DATE))
|
|
202
|
+
named.press("Tab")
|
|
203
|
+
case "radio":
|
|
204
|
+
form.locator(
|
|
205
|
+
f"input[type=radio][name='{field_id}'][value={json.dumps(value)}]"
|
|
206
|
+
).check()
|
|
207
|
+
case "box":
|
|
208
|
+
named.set_checked(value == "Yes")
|
|
209
|
+
case "boxes":
|
|
210
|
+
for option in [answer] if isinstance(answer, str) else answer:
|
|
211
|
+
exact = re.compile(f"^\\s*{re.escape(option)}\\s*$")
|
|
212
|
+
box = form.locator(
|
|
213
|
+
".multi-option", has=page.locator(f"[name='{field_id}']")
|
|
214
|
+
)
|
|
215
|
+
box = box.filter(has_text=exact)
|
|
216
|
+
box.locator("input").first.check()
|
|
217
|
+
case "select" | "region":
|
|
218
|
+
_pick(page, field_id, value, exact=kind == "select")
|
|
219
|
+
case "file":
|
|
220
|
+
_attach(page, field_id, value)
|
|
221
|
+
|
|
222
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
223
|
+
values = page.locator(FORM).evaluate(_READ_BACK, list(self._kinds.items()))
|
|
224
|
+
out: dict[str, Answer] = {}
|
|
225
|
+
for field_id, value in values.items():
|
|
226
|
+
if self._kinds[field_id] == "date" and value:
|
|
227
|
+
with suppress(ValueError):
|
|
228
|
+
value = datetime.strptime(value, DATE).date().isoformat()
|
|
229
|
+
if value not in (None, "", []):
|
|
230
|
+
out[field_id] = value
|
|
231
|
+
return out
|
|
232
|
+
|
|
233
|
+
def submit(self, page: Page) -> str | None:
|
|
234
|
+
"""Clicks Submit Application; the evidence is the answer to the application's POST."""
|
|
235
|
+
host = urlsplit(page.url).hostname
|
|
236
|
+
with page.expect_response(
|
|
237
|
+
lambda r: (
|
|
238
|
+
r.request.method == "POST"
|
|
239
|
+
and urlsplit(r.url).hostname == host
|
|
240
|
+
and not r.url.endswith(UPLOAD)
|
|
241
|
+
),
|
|
242
|
+
timeout=SUBMIT_TIMEOUT_MS,
|
|
243
|
+
) as applied:
|
|
244
|
+
page.locator(SUBMIT).click()
|
|
245
|
+
response = applied.value
|
|
246
|
+
if page.locator("iframe[src*='recaptcha'][title*='challenge']").count():
|
|
247
|
+
raise Blocked("captcha: a reCAPTCHA challenge showed on submit")
|
|
248
|
+
if not response.ok:
|
|
249
|
+
raise RuntimeError(f"apply answered {response.status}: {response.text()[:200]}")
|
|
250
|
+
return f"{response.url} {response.status}"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _questions(props: str) -> dict[str, str]:
|
|
254
|
+
"""Each question's text by its id, from the job's props."""
|
|
255
|
+
with suppress(ValueError, KeyError, TypeError):
|
|
256
|
+
table = json.loads(props)["jobObj"]["table"]
|
|
257
|
+
return {str(q["table"]["id"]): q["table"]["question"] for q in table.get("questions") or []}
|
|
258
|
+
return {}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _field(control: dict, questions: dict[str, str], options: list[str] | None) -> Field:
|
|
262
|
+
"""A field from a rendered control; a history entry's field labelled with its question."""
|
|
263
|
+
field_id, kind = control["id"], control["kind"]
|
|
264
|
+
label = control["label"] or field_id
|
|
265
|
+
required = bool(REQUIRED.search(label))
|
|
266
|
+
label = REQUIRED.sub("", label)
|
|
267
|
+
if entry := re.match(r"job_assessment\.question_(\d+)\.response\.\d+\.", field_id):
|
|
268
|
+
label = f"{questions.get(entry[1], 'History')} {label}"
|
|
269
|
+
if kind == "unhandled":
|
|
270
|
+
reason = f"unhandled_rendering: {field_id} is a {control['typed']}"
|
|
271
|
+
return Field(field_id, label, FieldType.TEXT, required, unfillable=reason)
|
|
272
|
+
if kind == "box":
|
|
273
|
+
options = YES_NO
|
|
274
|
+
required = "disclosure_agree" in field_id # an agreement, not a preference
|
|
275
|
+
elif kind in ("radio", "boxes"):
|
|
276
|
+
options = control["options"]
|
|
277
|
+
max_length = control.get("maxLength") if kind == "text" else None
|
|
278
|
+
return Field(field_id, label, TYPES[kind], required, options, max_length)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _hide_banner(page: Page) -> None:
|
|
282
|
+
"""Hides the cookie banner, which covers the form's bottom, without answering it."""
|
|
283
|
+
with suppress(Exception):
|
|
284
|
+
page.locator(BANNER).evaluate_all("es => es.forEach(e => e.style.display = 'none')")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _combobox(page: Page, field_id: str) -> Locator:
|
|
288
|
+
return page.locator(f"{FORM} input[role=combobox][id='{field_id}']")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _menu(page: Page, field_id: str) -> list[str]:
|
|
292
|
+
"""A react-select's options, read by opening its menu and closing it again."""
|
|
293
|
+
box = _combobox(page, field_id)
|
|
294
|
+
box.focus()
|
|
295
|
+
box.press("ArrowDown")
|
|
296
|
+
options = page.locator(".Select-menu-outer .Select-option")
|
|
297
|
+
with suppress(Timeout):
|
|
298
|
+
options.first.wait_for(timeout=MENU_TIMEOUT_MS)
|
|
299
|
+
texts = [" ".join(t.split()) for t in options.all_inner_texts()]
|
|
300
|
+
box.press("Escape")
|
|
301
|
+
return texts
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _pick(page: Page, field_id: str, value: str, exact: bool) -> None:
|
|
305
|
+
"""Types into a react-select and clicks the option that is the answer, or for a typeahead
|
|
306
|
+
the first that starts with it."""
|
|
307
|
+
box = _combobox(page, field_id)
|
|
308
|
+
box.focus()
|
|
309
|
+
box.press_sequentially(value)
|
|
310
|
+
options = page.locator(".Select-menu-outer .Select-option")
|
|
311
|
+
try:
|
|
312
|
+
options.first.wait_for(timeout=MENU_TIMEOUT_MS)
|
|
313
|
+
except Timeout:
|
|
314
|
+
box.press("Escape")
|
|
315
|
+
raise NotTaken(f"no option {value!r} in {field_id}") from None
|
|
316
|
+
texts = [" ".join(t.split()) for t in options.all_inner_texts()]
|
|
317
|
+
hit = next((i for i, t in enumerate(texts) if t == value), None)
|
|
318
|
+
if hit is None and not exact:
|
|
319
|
+
hit = next((i for i, t in enumerate(texts) if t.startswith(value)), None)
|
|
320
|
+
if hit is None:
|
|
321
|
+
box.press("Escape")
|
|
322
|
+
raise NotTaken(f"no option {value!r} in {field_id}")
|
|
323
|
+
options.nth(hit).click()
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _attach(page: Page, field_id: str, path: str) -> None:
|
|
327
|
+
"""Attaches a file through its Dropzone area's file chooser, and waits for its upload."""
|
|
328
|
+
zone = page.locator(f"{FORM} [id='{field_id}'] .dropzone")
|
|
329
|
+
with page.expect_file_chooser() as chooser:
|
|
330
|
+
zone.click()
|
|
331
|
+
chooser.value.set_files(path)
|
|
332
|
+
name = path.rsplit("/", 1)[-1]
|
|
333
|
+
done = zone.locator(".dz-complete .dz-filename", has_text=name)
|
|
334
|
+
with suppress(Timeout):
|
|
335
|
+
done.first.wait_for(timeout=UPLOAD_TIMEOUT_MS)
|
|
@@ -398,7 +398,9 @@ kind = "ats"
|
|
|
398
398
|
share = 0.0028
|
|
399
399
|
account = "none"
|
|
400
400
|
hosting = ["hosted"]
|
|
401
|
+
adapter = "hiringthing"
|
|
401
402
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
403
|
+
dry_run_verified = { date = 2026-10-22, version = "0.0.3", by = "adapter_gate", commit = "cce148b" }
|
|
402
404
|
|
|
403
405
|
[jibe]
|
|
404
406
|
kind = "aggregator"
|
|
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
|