atsfill 0.0.4.dev25__tar.gz → 0.0.4.dev28__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.
Files changed (29) hide show
  1. {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev28}/PKG-INFO +2 -2
  2. {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev28}/README.md +1 -1
  3. {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev28}/pyproject.toml +1 -1
  4. {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev28}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev28}/src/atsfill/adapters/__init__.py +2 -0
  6. atsfill-0.0.4.dev28/src/atsfill/adapters/workable.py +423 -0
  7. {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev28}/src/atsfill/core/browser.py +10 -2
  8. {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev28}/src/atsfill/core/registry.py +1 -1
  9. {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev28}/src/atsfill/registry.toml +2 -0
  10. {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev28}/LICENSE +0 -0
  11. {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev28}/src/atsfill/__init__.py +0 -0
  12. {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev28}/src/atsfill/adapters/ashby.py +0 -0
  13. {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev28}/src/atsfill/adapters/greenhouse.py +0 -0
  14. {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev28}/src/atsfill/adapters/manatal.py +0 -0
  15. {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev28}/src/atsfill/adapters/oracle.py +0 -0
  16. {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev28}/src/atsfill/adapters/paylocity.py +0 -0
  17. {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev28}/src/atsfill/adapters/smartrecruiters.py +0 -0
  18. {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev28}/src/atsfill/adapters/teamtailor.py +0 -0
  19. {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev28}/src/atsfill/adapters/wttj.py +0 -0
  20. {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev28}/src/atsfill/cli.py +0 -0
  21. {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev28}/src/atsfill/core/__init__.py +0 -0
  22. {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev28}/src/atsfill/core/accounts.py +0 -0
  23. {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev28}/src/atsfill/core/adapter.py +0 -0
  24. {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev28}/src/atsfill/core/attempts.py +0 -0
  25. {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev28}/src/atsfill/core/commands.py +0 -0
  26. {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev28}/src/atsfill/core/matching.py +0 -0
  27. {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev28}/src/atsfill/core/paths.py +0 -0
  28. {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev28}/src/atsfill/core/results.py +0 -0
  29. {atsfill-0.0.4.dev25 → atsfill-0.0.4.dev28}/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.dev25
3
+ Version: 0.0.4.dev28
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
- ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
56
+ ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![workable: dry run verified 2026-10-20](https://img.shields.io/badge/workable-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
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
- ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
32
+ ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![workable: dry run verified 2026-10-20](https://img.shields.io/badge/workable-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
33
33
  <!-- /badges -->
34
34
 
35
35
  | System | Account needed | `read` without a browser |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.4.dev25"
3
+ version = "0.0.4.dev28"
4
4
  description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.4.dev25"
3
+ version = "0.0.4.dev28"
4
4
  description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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']}"
@@ -14,6 +14,7 @@ from concurrent.futures import Future
14
14
  from contextlib import contextmanager
15
15
  from pathlib import Path
16
16
 
17
+ from camoufox.addons import DefaultAddons, maybe_download_addons
17
18
  from camoufox.exceptions import CamoufoxNotInstalled
18
19
  from camoufox.fingerprints import Screen, generate_fingerprint
19
20
  from camoufox.pkgman import installed_verstr
@@ -66,8 +67,15 @@ def version() -> str:
66
67
 
67
68
 
68
69
  def install() -> int:
69
- """Fetches the pinned browser build."""
70
- return subprocess.call([sys.executable, "-m", "camoufox", "fetch", f"official/{BUILD}"])
70
+ """Fetches the pinned browser build unless it is installed, then the add-ons every launch
71
+ loads, so that browsers started side by side do not each download them."""
72
+ if version() != BUILD:
73
+ subprocess.call([sys.executable, "-m", "camoufox", "fetch", f"official/{BUILD}"])
74
+ if version() != BUILD: # `camoufox fetch` exits 0 when it fails
75
+ print(f"the Camoufox build {BUILD} is not installed", file=sys.stderr)
76
+ return 1
77
+ maybe_download_addons(list(DefaultAddons))
78
+ return 0
71
79
 
72
80
 
73
81
  def identity(data_dir: Path) -> dict:
@@ -181,7 +181,7 @@ def backlog(registry: dict[str, System] | None = None) -> str:
181
181
  "# Hiring systems",
182
182
  "",
183
183
  "Generated from [src/atsfill/registry.toml](../../src/atsfill/registry.toml) by"
184
- " `uv run scripts/backlog.py`. The registry is described in"
184
+ " `uv run python -m scripts.backlog`. The registry is described in"
185
185
  " [ADR 0012](../adr/0012-systems-registry.md), the order in"
186
186
  " [ADR 0013](../adr/0013-survey-and-adapter-rounds.md): no account first, blocked last,"
187
187
  " then by share of postings.",
@@ -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