atsfill 0.0.4.dev25__tar.gz → 0.0.4.dev26__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.dev25 → atsfill-0.0.4.dev26}/PKG-INFO +2 -2
- {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev26}/README.md +1 -1
- {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev26}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev26}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev26}/src/atsfill/adapters/__init__.py +2 -0
- atsfill-0.0.4.dev26/src/atsfill/adapters/workable.py +423 -0
- {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev26}/src/atsfill/registry.toml +2 -0
- {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev26}/LICENSE +0 -0
- {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev26}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev26}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev26}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev26}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev26}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev26}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev26}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev26}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev26}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev26}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev26}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev26}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev26}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev26}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev26}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev26}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev26}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev26}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev26}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev26}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev26}/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.dev26
|
|
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
|
|
@@ -53,7 +53,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
53
53
|
## Supported hiring systems
|
|
54
54
|
|
|
55
55
|
<!-- badges -->
|
|
56
|
-
       
|
|
56
|
+
        
|
|
57
57
|
<!-- /badges -->
|
|
58
58
|
|
|
59
59
|
| 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.oracle import Oracle
|
|
|
5
5
|
from atsfill.adapters.paylocity import Paylocity
|
|
6
6
|
from atsfill.adapters.smartrecruiters import SmartRecruiters
|
|
7
7
|
from atsfill.adapters.teamtailor import Teamtailor
|
|
8
|
+
from atsfill.adapters.workable import Workable
|
|
8
9
|
from atsfill.adapters.wttj import WelcomeToTheJungle
|
|
9
10
|
from atsfill.core.adapter import Adapter
|
|
10
11
|
|
|
@@ -17,4 +18,5 @@ ADAPTERS: list[Adapter] = [
|
|
|
17
18
|
Oracle(),
|
|
18
19
|
Manatal(),
|
|
19
20
|
Paylocity(),
|
|
21
|
+
Workable(),
|
|
20
22
|
]
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"""Workable (`apply.workable.com` job pages and their application form), as
|
|
2
|
+
docs/adapters/workable.md observes.
|
|
3
|
+
|
|
4
|
+
Every field sits under an element whose `data-ui` is its id in Workable's form API, which
|
|
5
|
+
`read_api` reads. Nothing but an attached file reaches Workable before submit, so the
|
|
6
|
+
read-back is the page.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
import urllib.request
|
|
12
|
+
from contextlib import suppress
|
|
13
|
+
from datetime import date
|
|
14
|
+
from typing import Any
|
|
15
|
+
from urllib.parse import urlsplit
|
|
16
|
+
|
|
17
|
+
from atsfill.core import browser
|
|
18
|
+
from atsfill.core.adapter import Unsupported
|
|
19
|
+
from atsfill.core.browser import Locator, Page, Timeout
|
|
20
|
+
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
21
|
+
|
|
22
|
+
HOSTS = re.compile(r"^(apply|[\w-]+)\.workable\.com$")
|
|
23
|
+
JOB = re.compile(r"^(?:/[^/]+)?/j/([0-9A-F]{10})\b")
|
|
24
|
+
API = "https://apply.workable.com/api/v1/jobs/{code}/form"
|
|
25
|
+
APPLIED = re.compile(r"/api/v1/jobs/[0-9A-F]{10}/apply$")
|
|
26
|
+
FORM = "form[data-ui=application-form]"
|
|
27
|
+
COOKIES = "[data-ui=cookie-consent-decline]"
|
|
28
|
+
FORM_TIMEOUT_MS = 30_000
|
|
29
|
+
COOKIE_TIMEOUT_MS = 5_000
|
|
30
|
+
OPTIONS_TIMEOUT_MS = 5_000
|
|
31
|
+
CHECK_TIMEOUT_MS = 3_000
|
|
32
|
+
UPLOAD_TIMEOUT_MS = 30_000
|
|
33
|
+
SUBMIT_TIMEOUT_MS = 90_000
|
|
34
|
+
YES_NO = ["Yes", "No"]
|
|
35
|
+
API_TYPES = {
|
|
36
|
+
"text": FieldType.TEXT,
|
|
37
|
+
"email": FieldType.TEXT,
|
|
38
|
+
"paragraph": FieldType.TEXT,
|
|
39
|
+
"number": FieldType.TEXT,
|
|
40
|
+
"phone": FieldType.PHONE,
|
|
41
|
+
"file": FieldType.FILE,
|
|
42
|
+
"date": FieldType.DATE,
|
|
43
|
+
"boolean": FieldType.CHOICE,
|
|
44
|
+
"dropdown": FieldType.CHOICE,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# The fields in page order: each outermost `data-ui` element of a section, by what it holds.
|
|
48
|
+
_READ = """form => {
|
|
49
|
+
const text = el => {
|
|
50
|
+
if (!el) return "";
|
|
51
|
+
const copy = el.cloneNode(true);
|
|
52
|
+
copy.querySelectorAll("small").forEach(e => e.remove());
|
|
53
|
+
return copy.textContent.replace(/\\s+/g, " ").trim();
|
|
54
|
+
};
|
|
55
|
+
const optionLabel = el =>
|
|
56
|
+
text(document.getElementById((el.getAttribute("aria-labelledby") || "").split(" ")[1]));
|
|
57
|
+
const found = [];
|
|
58
|
+
for (const section of form.querySelectorAll("[data-ui=section-fields]")) {
|
|
59
|
+
for (const el of section.querySelectorAll("[data-ui]:not([data-ui=calendar-icon])")) {
|
|
60
|
+
if (el.parentElement.closest("[data-ui]") !== section) continue;
|
|
61
|
+
const id = el.getAttribute("data-ui");
|
|
62
|
+
const wrapper = [...section.children].find(c => c.contains(el));
|
|
63
|
+
const named = el.matches("[aria-labelledby]") ? el : el.querySelector("[aria-labelledby]");
|
|
64
|
+
const labelId = (named?.getAttribute("aria-labelledby") || "").split(" ")[0];
|
|
65
|
+
const field = {id, label: text(document.getElementById(labelId) ||
|
|
66
|
+
document.getElementById(`${id}_label`)),
|
|
67
|
+
required: [...wrapper.querySelectorAll("strong")].some(s => s.textContent.trim() === "*")};
|
|
68
|
+
const input = el.matches("input, textarea") ? el : null;
|
|
69
|
+
if (el.querySelector("[data-ui=add-section]")) {
|
|
70
|
+
field.kind = "group";
|
|
71
|
+
field.required = !document.getElementById(`${id}_label`)?.querySelector("small");
|
|
72
|
+
} else if (input?.type === "file") {
|
|
73
|
+
field.kind = "file";
|
|
74
|
+
field.accept = input.accept ? input.accept.split(",").filter(a => a.startsWith(".")) : null;
|
|
75
|
+
} else if (el.querySelector("input[type=tel]")) field.kind = "phone";
|
|
76
|
+
else if (el.matches("[data-input-type=select]")) field.kind = "select";
|
|
77
|
+
else if (el.matches("[role=radiogroup]")) {
|
|
78
|
+
const radios = [...el.querySelectorAll("[role=radio]")];
|
|
79
|
+
const values = radios.map(r => r.querySelector("input").value);
|
|
80
|
+
const yesNo = values.length === 2 && values.includes("true") && values.includes("false");
|
|
81
|
+
field.kind = yesNo ? "yes_no" : "radio";
|
|
82
|
+
field.options = yesNo ? null : radios.map(optionLabel);
|
|
83
|
+
} else if (el.matches("[role=group]")) {
|
|
84
|
+
field.kind = "checkboxes";
|
|
85
|
+
field.options = [...el.querySelectorAll("[role=checkbox]")].map(optionLabel);
|
|
86
|
+
} else if (el.querySelector("input[type=checkbox]")) {
|
|
87
|
+
field.kind = "consent";
|
|
88
|
+
field.required = true; // unmarked, but the API's: it takes only a Yes
|
|
89
|
+
field.label = text(document.getElementById(labelId));
|
|
90
|
+
} else if (input && el.parentElement.querySelector("[data-ui=calendar-icon]")) {
|
|
91
|
+
field.kind = "date";
|
|
92
|
+
field.format = input.placeholder;
|
|
93
|
+
} else if (input) {
|
|
94
|
+
field.kind = input.id ? "text" : "number"; // a number input alone has no id
|
|
95
|
+
field.maxLength = input.maxLength > 0 ? input.maxLength : null;
|
|
96
|
+
} else field.kind = el.tagName.toLowerCase();
|
|
97
|
+
found.push(field);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return found;
|
|
101
|
+
}"""
|
|
102
|
+
|
|
103
|
+
# What each field shows: a choice its checked option's label, a file its attached name (a photo
|
|
104
|
+
# its preview's alt), the phone its country's dial code then its number.
|
|
105
|
+
_READ_BACK = """(form, kinds) => {
|
|
106
|
+
const text = el => el ? el.textContent.replace(/\\s+/g, " ").trim() : null;
|
|
107
|
+
const optionLabel = el =>
|
|
108
|
+
text(document.getElementById((el.getAttribute("aria-labelledby") || "").split(" ")[1]));
|
|
109
|
+
const out = {};
|
|
110
|
+
for (const [id, kind] of Object.entries(kinds)) {
|
|
111
|
+
const el = form.querySelector(`[data-ui="${id}"]`);
|
|
112
|
+
if (!el) continue;
|
|
113
|
+
if (kind === "file") {
|
|
114
|
+
const zone = el.closest("[data-role=dropzone]");
|
|
115
|
+
const preview = zone?.querySelector("[data-role=preview] img"); // a photo, named by its alt
|
|
116
|
+
out[id] = preview ? preview.alt : text(zone?.querySelector("[data-id=filename]"));
|
|
117
|
+
} else if (kind === "phone") {
|
|
118
|
+
const dial = (el.querySelector("button[aria-label]")?.getAttribute("aria-label") || "")
|
|
119
|
+
.match(/\\((\\+\\d+)\\)/);
|
|
120
|
+
const number = el.querySelector("input[type=tel]").value;
|
|
121
|
+
out[id] = dial && number ? `${dial[1]} ${number}` : null;
|
|
122
|
+
} else if (kind === "yes_no") {
|
|
123
|
+
const value = el.querySelector("[role=radio][aria-checked=true] input")?.value;
|
|
124
|
+
out[id] = value === "true" ? "Yes" : value === "false" ? "No" : null;
|
|
125
|
+
} else if (kind === "radio") {
|
|
126
|
+
const radio = el.querySelector("[role=radio][aria-checked=true]");
|
|
127
|
+
out[id] = radio ? optionLabel(radio) : null;
|
|
128
|
+
} else if (kind === "checkboxes") {
|
|
129
|
+
out[id] = [...el.querySelectorAll("[role=checkbox][aria-checked=true]")].map(optionLabel);
|
|
130
|
+
} else if (kind === "consent") {
|
|
131
|
+
out[id] = el.querySelector("[role=checkbox]").getAttribute("aria-checked") === "true"
|
|
132
|
+
? "Yes" : "No";
|
|
133
|
+
} else if (kind === "select") {
|
|
134
|
+
out[id] = el.querySelector("[role=combobox]").value.trim() || null;
|
|
135
|
+
} else if (kind === "number") out[id] = el.value.replace(/,/g, "");
|
|
136
|
+
else out[id] = el.value;
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}"""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class Workable:
|
|
143
|
+
name = "workable"
|
|
144
|
+
side_effects = [
|
|
145
|
+
SideEffect("upload", "hiring_system", "an attached file is stored by Workable at once"),
|
|
146
|
+
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
def __init__(self) -> None:
|
|
150
|
+
self._kinds: dict[str, str] = {} # how the page renders each field
|
|
151
|
+
self._formats: dict[str, str] = {} # a date input's placeholder, MM/DD/YYYY or other
|
|
152
|
+
|
|
153
|
+
def detect(self, url: str, page: Page | None) -> bool:
|
|
154
|
+
return _code(url) is not None
|
|
155
|
+
|
|
156
|
+
def posting(self, url: str) -> str:
|
|
157
|
+
return f"https://apply.workable.com/j/{_code(url) or url}"
|
|
158
|
+
|
|
159
|
+
def read_api(self, url: str) -> list[Field] | None:
|
|
160
|
+
"""The fields from the form API the page itself loads, unless it has a required group."""
|
|
161
|
+
code = _code(url)
|
|
162
|
+
if code is None:
|
|
163
|
+
return None
|
|
164
|
+
try:
|
|
165
|
+
request = urllib.request.Request(
|
|
166
|
+
API.format(code=code), headers={"User-Agent": "atsfill"}
|
|
167
|
+
)
|
|
168
|
+
with urllib.request.urlopen(request, timeout=20) as response:
|
|
169
|
+
return _api_fields(json.load(response))
|
|
170
|
+
except OSError, ValueError, KeyError, TypeError:
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
def read_form(self, page: Page) -> list[Field]:
|
|
174
|
+
if "/apply" not in urlsplit(page.url).path:
|
|
175
|
+
page.goto(f"{self.posting(page.url)}/apply/")
|
|
176
|
+
form = page.locator(FORM)
|
|
177
|
+
try:
|
|
178
|
+
form.wait_for(timeout=FORM_TIMEOUT_MS)
|
|
179
|
+
except Timeout:
|
|
180
|
+
raise Unsupported("posting_gone: no application form on the page") from None
|
|
181
|
+
_decline_cookies(page)
|
|
182
|
+
fields, self._kinds, self._formats = [], {}, {}
|
|
183
|
+
for raw in form.evaluate(_READ):
|
|
184
|
+
field = self._field(page, raw)
|
|
185
|
+
if field is not None:
|
|
186
|
+
fields.append(field)
|
|
187
|
+
self._kinds[field.id] = raw["kind"]
|
|
188
|
+
if raw["kind"] == "date":
|
|
189
|
+
self._formats[field.id] = raw["format"]
|
|
190
|
+
return fields
|
|
191
|
+
|
|
192
|
+
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
193
|
+
_decline_cookies(page, wait=False)
|
|
194
|
+
# attaching the resume empties the photo, so it goes first
|
|
195
|
+
for field_id, answer in sorted(answers.items(), key=lambda a: a[0] != "resume"):
|
|
196
|
+
values = [answer] if isinstance(answer, str) else answer
|
|
197
|
+
field = page.locator(f"{FORM} [data-ui='{field_id}']").first
|
|
198
|
+
match self._kinds[field_id]:
|
|
199
|
+
case "file": # a photo goes through a crop dialog first
|
|
200
|
+
field.set_input_files(values[0])
|
|
201
|
+
zone = field.locator("xpath=ancestor::*[@data-role='dropzone']")
|
|
202
|
+
crop = page.locator("button[data-ui=crop-image]")
|
|
203
|
+
with suppress(Timeout):
|
|
204
|
+
zone.locator("[data-id=filename]").or_(crop).first.wait_for(
|
|
205
|
+
timeout=UPLOAD_TIMEOUT_MS
|
|
206
|
+
)
|
|
207
|
+
if crop.is_visible():
|
|
208
|
+
crop.click()
|
|
209
|
+
crop.wait_for(state="hidden", timeout=UPLOAD_TIMEOUT_MS)
|
|
210
|
+
case "phone": # the number's dial code picks the country
|
|
211
|
+
number = field.locator("input[type=tel]")
|
|
212
|
+
number.fill("")
|
|
213
|
+
number.press_sequentially(values[0], delay=20)
|
|
214
|
+
case "date":
|
|
215
|
+
if typed := _typed_date(values[0], self._formats[field_id]):
|
|
216
|
+
field.click()
|
|
217
|
+
field.press_sequentially(typed, delay=20)
|
|
218
|
+
field.press("Tab")
|
|
219
|
+
case "yes_no":
|
|
220
|
+
value = {"Yes": "true", "No": "false"}.get(values[0])
|
|
221
|
+
radio = field.locator("[role=radio]").filter(
|
|
222
|
+
has=page.locator(f"input[value='{value}']")
|
|
223
|
+
)
|
|
224
|
+
if value and radio.count():
|
|
225
|
+
_check(radio, True)
|
|
226
|
+
case "radio":
|
|
227
|
+
_pick(page, field, "[role=radio]", values[0])
|
|
228
|
+
case "checkboxes":
|
|
229
|
+
for value in values:
|
|
230
|
+
_pick(page, field, "[role=checkbox]", value)
|
|
231
|
+
case "consent": # the box, not its label: the label holds a link
|
|
232
|
+
_check(field.locator("[role=checkbox]"), answer == "Yes")
|
|
233
|
+
case "select":
|
|
234
|
+
_select(page, field_id, values[0])
|
|
235
|
+
case _:
|
|
236
|
+
field.fill(values[0])
|
|
237
|
+
|
|
238
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
239
|
+
values = page.locator(FORM).evaluate(_READ_BACK, self._kinds)
|
|
240
|
+
for field_id, fmt in self._formats.items():
|
|
241
|
+
values[field_id] = _iso_date(values.get(field_id) or "", fmt)
|
|
242
|
+
return {k: v for k, v in values.items() if v not in (None, "", [])}
|
|
243
|
+
|
|
244
|
+
def submit(self, page: Page) -> str | None:
|
|
245
|
+
"""Clicks submit; the evidence is the `/apply` call's answer, after Turnstile's check."""
|
|
246
|
+
with page.expect_response(
|
|
247
|
+
lambda r: r.request.method == "POST" and APPLIED.search(r.url) is not None,
|
|
248
|
+
timeout=SUBMIT_TIMEOUT_MS,
|
|
249
|
+
) as applied:
|
|
250
|
+
browser.click(page.locator(f"{FORM} button[data-ui=apply-button]"))
|
|
251
|
+
response = applied.value
|
|
252
|
+
if not response.ok:
|
|
253
|
+
raise RuntimeError(f"apply answered {response.status}: {response.text()[:200]}")
|
|
254
|
+
return f"{response.url} {response.status}"
|
|
255
|
+
|
|
256
|
+
def _field(self, page: Page, raw: dict[str, Any]) -> Field | None:
|
|
257
|
+
kind, options = raw["kind"], raw.get("options")
|
|
258
|
+
match kind:
|
|
259
|
+
case "text" | "number":
|
|
260
|
+
field_type = FieldType.TEXT
|
|
261
|
+
case "date":
|
|
262
|
+
field_type = FieldType.DATE
|
|
263
|
+
case "phone":
|
|
264
|
+
field_type = FieldType.PHONE
|
|
265
|
+
case "file":
|
|
266
|
+
field_type = FieldType.FILE
|
|
267
|
+
case "yes_no" | "consent":
|
|
268
|
+
field_type, options = FieldType.CHOICE, YES_NO
|
|
269
|
+
case "radio":
|
|
270
|
+
field_type = FieldType.CHOICE
|
|
271
|
+
case "checkboxes":
|
|
272
|
+
field_type = FieldType.MULTI_CHOICE
|
|
273
|
+
case "select":
|
|
274
|
+
field_type, options = FieldType.CHOICE, _options(page, raw["id"])
|
|
275
|
+
case "group" if not raw["required"]:
|
|
276
|
+
return None # an optional education or experience entry, left out
|
|
277
|
+
case _:
|
|
278
|
+
raise Unsupported(f"form_shape: {raw['id']} is a {kind}")
|
|
279
|
+
return Field(
|
|
280
|
+
raw["id"],
|
|
281
|
+
raw["label"],
|
|
282
|
+
field_type,
|
|
283
|
+
raw["required"],
|
|
284
|
+
options,
|
|
285
|
+
raw.get("maxLength"),
|
|
286
|
+
raw.get("accept"),
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _code(url: str) -> str | None:
|
|
291
|
+
"""The posting's shortcode, from `apply.workable.com[/<account>]/j/<code>`."""
|
|
292
|
+
parts = urlsplit(url)
|
|
293
|
+
if not HOSTS.match(parts.hostname or ""):
|
|
294
|
+
return None
|
|
295
|
+
match = JOB.match(parts.path)
|
|
296
|
+
return match[1] if match else None
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _api_fields(form: list[dict[str, Any]]) -> list[Field] | None:
|
|
300
|
+
fields = []
|
|
301
|
+
for entry in (f for section in form for f in section["fields"]):
|
|
302
|
+
kind = entry["type"]
|
|
303
|
+
if kind == "group":
|
|
304
|
+
if entry["required"]:
|
|
305
|
+
return None
|
|
306
|
+
continue
|
|
307
|
+
options = [o["value"].strip() for o in entry.get("options") or []] or None
|
|
308
|
+
if kind == "multiple":
|
|
309
|
+
single = entry.get("singleOption", True)
|
|
310
|
+
field_type = FieldType.CHOICE if single else FieldType.MULTI_CHOICE
|
|
311
|
+
elif kind == "boolean":
|
|
312
|
+
field_type, options = FieldType.CHOICE, YES_NO
|
|
313
|
+
elif kind in API_TYPES:
|
|
314
|
+
field_type = API_TYPES[kind]
|
|
315
|
+
else:
|
|
316
|
+
return None
|
|
317
|
+
accept = entry.get("supportedFileTypes") if kind == "file" else None
|
|
318
|
+
fields.append(
|
|
319
|
+
Field(
|
|
320
|
+
entry["id"],
|
|
321
|
+
entry.get("label", ""),
|
|
322
|
+
field_type,
|
|
323
|
+
entry["required"],
|
|
324
|
+
options,
|
|
325
|
+
entry.get("maxLength"),
|
|
326
|
+
accept,
|
|
327
|
+
)
|
|
328
|
+
)
|
|
329
|
+
return fields
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _decline_cookies(page: Page, wait: bool = True) -> None:
|
|
333
|
+
"""Declines the cookie banner, a modal dialog that leaves the form inert while it is open;
|
|
334
|
+
it shows a moment after the page."""
|
|
335
|
+
decline = page.locator(COOKIES)
|
|
336
|
+
with suppress(Timeout):
|
|
337
|
+
if wait:
|
|
338
|
+
decline.wait_for(timeout=COOKIE_TIMEOUT_MS)
|
|
339
|
+
if decline.is_visible():
|
|
340
|
+
decline.click()
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _exact(value: str) -> re.Pattern[str]:
|
|
344
|
+
return re.compile(rf"^\s*{re.escape(value)}\s*$")
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _pick(page: Page, field: Locator, role: str, value: str) -> None:
|
|
348
|
+
"""Checks the option labelled `value`, unless it is already checked. An option names its
|
|
349
|
+
label second in `aria-labelledby`, after the question."""
|
|
350
|
+
for option in field.locator(role).all():
|
|
351
|
+
label_id = (option.get_attribute("aria-labelledby") or "").split()[-1]
|
|
352
|
+
if re.match(_exact(value), page.locator(f"[id='{label_id}']").text_content() or ""):
|
|
353
|
+
_check(option, True)
|
|
354
|
+
return
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _check(option: Locator, checked: bool) -> None:
|
|
358
|
+
"""Clicks a radio or checkbox unless it already is `checked`. Its `aria-checked` is the
|
|
359
|
+
form's state: the hidden input it holds is remounted, and catches up, on a later render."""
|
|
360
|
+
state = "true" if checked else "false"
|
|
361
|
+
if option.get_attribute("aria-checked") == state:
|
|
362
|
+
return
|
|
363
|
+
browser.click(option)
|
|
364
|
+
with suppress(Timeout):
|
|
365
|
+
option.and_(option.page.locator(f"[aria-checked={state}]")).wait_for(
|
|
366
|
+
timeout=CHECK_TIMEOUT_MS
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _options(page: Page, field_id: str) -> list[str]:
|
|
371
|
+
"""A dropdown's options, from opening it."""
|
|
372
|
+
box = page.locator(f"[id='input_{field_id}_input']")
|
|
373
|
+
box.click()
|
|
374
|
+
labels = page.locator(f"[id='input_{field_id}_listbox'] [data-role=option-label]")
|
|
375
|
+
with suppress(Timeout):
|
|
376
|
+
labels.first.wait_for(timeout=OPTIONS_TIMEOUT_MS)
|
|
377
|
+
options = [t.strip() for t in labels.all_text_contents()]
|
|
378
|
+
box.press("Escape")
|
|
379
|
+
_closed(page, field_id)
|
|
380
|
+
return options
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _select(page: Page, field_id: str, value: str) -> None:
|
|
384
|
+
"""Opens a dropdown and clicks the option with the exact text; none leaves it empty."""
|
|
385
|
+
page.locator(f"[id='input_{field_id}_input']").click()
|
|
386
|
+
option = page.locator(f"[id='input_{field_id}_listbox'] [role=option]").filter(
|
|
387
|
+
has=page.locator("[data-role=option-label]", has_text=_exact(value))
|
|
388
|
+
)
|
|
389
|
+
try:
|
|
390
|
+
option.first.wait_for(timeout=OPTIONS_TIMEOUT_MS)
|
|
391
|
+
except Timeout:
|
|
392
|
+
page.keyboard.press("Escape")
|
|
393
|
+
else:
|
|
394
|
+
option.first.click()
|
|
395
|
+
_closed(page, field_id)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _closed(page: Page, field_id: str) -> None:
|
|
399
|
+
"""Waits for a dropdown's list to leave the page: closing, it still takes clicks."""
|
|
400
|
+
with suppress(Timeout):
|
|
401
|
+
page.locator(f"[id='input_{field_id}_listbox']").wait_for(
|
|
402
|
+
state="detached", timeout=OPTIONS_TIMEOUT_MS
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _typed_date(answer: str, fmt: str) -> str | None:
|
|
407
|
+
"""The digits a date input takes for a YYYY-MM-DD answer, in its placeholder's order."""
|
|
408
|
+
try:
|
|
409
|
+
day = date.fromisoformat(answer)
|
|
410
|
+
except ValueError:
|
|
411
|
+
return None
|
|
412
|
+
parts = {"YYYY": f"{day.year:04}", "MM": f"{day.month:02}", "DD": f"{day.day:02}"}
|
|
413
|
+
return "".join(parts[p] for p in re.findall(r"YYYY|MM|DD", fmt.upper()))
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _iso_date(shown: str, fmt: str) -> str | None:
|
|
417
|
+
"""A date input's value, shown in its placeholder's order, as YYYY-MM-DD."""
|
|
418
|
+
order = re.findall(r"YYYY|MM|DD", fmt.upper())
|
|
419
|
+
numbers = re.findall(r"\d+", shown)
|
|
420
|
+
if len(order) != 3 or len(numbers) != 3:
|
|
421
|
+
return None
|
|
422
|
+
parts = dict(zip(order, numbers, strict=True))
|
|
423
|
+
return f"{parts['YYYY']}-{parts['MM']}-{parts['DD']}"
|
|
@@ -142,7 +142,9 @@ kind = "ats"
|
|
|
142
142
|
share = 0.0159
|
|
143
143
|
account = "none"
|
|
144
144
|
hosting = ["hosted"]
|
|
145
|
+
adapter = "workable"
|
|
145
146
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
147
|
+
dry_run_verified = { date = 2026-10-20, version = "0.0.3", by = "gate", commit = "76fb0fd" }
|
|
146
148
|
|
|
147
149
|
[energyjobline]
|
|
148
150
|
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
|