atsfill 0.0.4.dev28__tar.gz → 0.0.4.dev31__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 (32) hide show
  1. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev31}/PKG-INFO +2 -2
  2. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev31}/README.md +1 -1
  3. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev31}/pyproject.toml +1 -1
  4. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev31}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev31}/src/atsfill/adapters/__init__.py +6 -0
  6. atsfill-0.0.4.dev31/src/atsfill/adapters/jazzhr.py +258 -0
  7. atsfill-0.0.4.dev31/src/atsfill/adapters/lever.py +252 -0
  8. atsfill-0.0.4.dev31/src/atsfill/adapters/paycor.py +329 -0
  9. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev31}/src/atsfill/core/commands.py +7 -1
  10. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev31}/src/atsfill/registry.toml +6 -0
  11. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev31}/LICENSE +0 -0
  12. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev31}/src/atsfill/__init__.py +0 -0
  13. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev31}/src/atsfill/adapters/ashby.py +0 -0
  14. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev31}/src/atsfill/adapters/greenhouse.py +0 -0
  15. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev31}/src/atsfill/adapters/manatal.py +0 -0
  16. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev31}/src/atsfill/adapters/oracle.py +0 -0
  17. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev31}/src/atsfill/adapters/paylocity.py +0 -0
  18. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev31}/src/atsfill/adapters/smartrecruiters.py +0 -0
  19. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev31}/src/atsfill/adapters/teamtailor.py +0 -0
  20. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev31}/src/atsfill/adapters/workable.py +0 -0
  21. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev31}/src/atsfill/adapters/wttj.py +0 -0
  22. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev31}/src/atsfill/cli.py +0 -0
  23. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev31}/src/atsfill/core/__init__.py +0 -0
  24. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev31}/src/atsfill/core/accounts.py +0 -0
  25. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev31}/src/atsfill/core/adapter.py +0 -0
  26. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev31}/src/atsfill/core/attempts.py +0 -0
  27. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev31}/src/atsfill/core/browser.py +0 -0
  28. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev31}/src/atsfill/core/matching.py +0 -0
  29. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev31}/src/atsfill/core/paths.py +0 -0
  30. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev31}/src/atsfill/core/registry.py +0 -0
  31. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev31}/src/atsfill/core/results.py +0 -0
  32. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev31}/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.dev28
3
+ Version: 0.0.4.dev31
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) ![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)
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) ![jazzhr: dry run verified 2026-10-20](https://img.shields.io/badge/jazzhr-dry%20run%20verified%202026--10--20-7aa2f7) ![lever: dry run verified 2026-10-20](https://img.shields.io/badge/lever-dry%20run%20verified%202026--10--20-7aa2f7) ![paycor: dry run verified 2026-10-20](https://img.shields.io/badge/paycor-dry%20run%20verified%202026--10--20-7aa2f7) ![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) ![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)
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) ![jazzhr: dry run verified 2026-10-20](https://img.shields.io/badge/jazzhr-dry%20run%20verified%202026--10--20-7aa2f7) ![lever: dry run verified 2026-10-20](https://img.shields.io/badge/lever-dry%20run%20verified%202026--10--20-7aa2f7) ![paycor: dry run verified 2026-10-20](https://img.shields.io/badge/paycor-dry%20run%20verified%202026--10--20-7aa2f7) ![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.dev28"
3
+ version = "0.0.4.dev31"
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.dev28"
3
+ version = "0.0.4.dev31"
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,7 +1,10 @@
1
1
  from atsfill.adapters.ashby import Ashby
2
2
  from atsfill.adapters.greenhouse import Greenhouse
3
+ from atsfill.adapters.jazzhr import JazzHR
4
+ from atsfill.adapters.lever import Lever
3
5
  from atsfill.adapters.manatal import Manatal
4
6
  from atsfill.adapters.oracle import Oracle
7
+ from atsfill.adapters.paycor import Paycor
5
8
  from atsfill.adapters.paylocity import Paylocity
6
9
  from atsfill.adapters.smartrecruiters import SmartRecruiters
7
10
  from atsfill.adapters.teamtailor import Teamtailor
@@ -19,4 +22,7 @@ ADAPTERS: list[Adapter] = [
19
22
  Manatal(),
20
23
  Paylocity(),
21
24
  Workable(),
25
+ Paycor(),
26
+ JazzHR(),
27
+ Lever(),
22
28
  ]
@@ -0,0 +1,258 @@
1
+ """JazzHR (`<account>.applytojob.com` job pages, which hold their application form), as
2
+ docs/adapters/jazzhr.md observes.
3
+
4
+ The form is plain HTML posted as a whole on submit, so the inputs are the read-back. A
5
+ questionnaire's checkboxes are joined into its hidden input only on submit, so they are read
6
+ by which boxes are checked.
7
+ """
8
+
9
+ import re
10
+ from contextlib import suppress
11
+ from typing import Any
12
+ from urllib.parse import urlsplit
13
+
14
+ from atsfill.core import browser
15
+ from atsfill.core.adapter import Blocked, Unsupported
16
+ from atsfill.core.browser import Page, Timeout
17
+ from atsfill.core.results import Answer, Field, FieldType, SideEffect
18
+
19
+ HOST = re.compile(r"^[\w-]+\.applytojob\.com$")
20
+ JOB = re.compile(r"^/apply/([A-Za-z0-9]{10})\b")
21
+ FORM = "#form_submit_new_resume"
22
+ COOKIES = "#tracking-consent-banner .reject"
23
+ CAPTCHA = "#resumator-recaptcha iframe[title=reCAPTCHA]"
24
+ CHALLENGE = "iframe[src*='recaptcha/api2/bframe']"
25
+ RESUME_TYPES = [".pdf", ".doc", ".docx", ".odt", ".txt", ".rtf"] # as the page states them
26
+ YES_NO = ["Yes", "No"]
27
+ FORM_TIMEOUT_MS = 20_000
28
+ COOKIE_TIMEOUT_MS = 3_000
29
+ CAPTCHA_TIMEOUT_MS = 20_000
30
+ SUBMIT_TIMEOUT_MS = 90_000
31
+
32
+ # The fields in page order: an input, select or textarea by its id, a questionnaire's
33
+ # checkboxes by their hidden answer input, radios by their name. An id drops `resumator-`,
34
+ # `questionnaire-` and `-value`.
35
+ _READ = """form => {
36
+ const text = el => {
37
+ if (!el) return "";
38
+ const copy = el.cloneNode(true);
39
+ copy.querySelectorAll(".asterisk, select, input").forEach(e => e.remove());
40
+ return copy.textContent.replace(/\\s+/g, " ").trim();
41
+ };
42
+ const short = id => id.replace(/^resumator-/, "").replace(/^questionnaire-/, "")
43
+ .replace(/-value$/, "");
44
+ const starred = el => !!el?.querySelector(".asterisk");
45
+ const shown = el => el.offsetParent !== null;
46
+ const disability = "#resumator-new-disability-message";
47
+ const found = [], radios = new Set();
48
+ const controls = form.querySelectorAll(
49
+ "input:not([type=hidden]), select, textarea, input.resumator-questionnaire-checkbox-answer");
50
+ for (const el of controls) {
51
+ if (["resumator-resumetext-value", "resumator-xml-value", "g-recaptcha-response"]
52
+ .includes(el.id) || el.classList.contains("resumator-questionnaire-checkbox")) continue;
53
+ const hidden = el.type === "file" ? el.id !== "resumator-resume-value"
54
+ : !shown(el) && el.type !== "hidden";
55
+ if (hidden) continue;
56
+ const group = el.closest(".form-group, .resumator-field-wrapper");
57
+ const groupLabel = group?.querySelector(".control-label, .resumator-label");
58
+ const own = el.id && form.querySelector(`label[for="${CSS.escape(el.id)}"]`);
59
+ const field = {id: short(el.id), label: text(own || el.closest("label") || groupLabel),
60
+ required: starred(own || groupLabel), selector: `#${CSS.escape(el.id)}`};
61
+ if (el.type === "hidden") { // a questionnaire's checkboxes
62
+ field.kind = "checkboxes";
63
+ field.options = [...group.querySelectorAll(".resumator-questionnaire-checkbox")]
64
+ .map(b => b.value);
65
+ } else if (el.type === "radio") {
66
+ if (radios.has(el.name)) continue;
67
+ radios.add(el.name);
68
+ const named = [...form.querySelectorAll(`input[type=radio][name="${el.name}"]`)];
69
+ field.kind = "radio";
70
+ field.id = short(el.name);
71
+ field.selector = `input[name="${el.name}"]`;
72
+ field.label = el.closest(disability) ? "Disability status" : text(groupLabel);
73
+ field.options = named.map(r => text(r.closest("label")));
74
+ } else if (el.type === "checkbox") {
75
+ field.kind = "consent";
76
+ } else if (el.tagName === "SELECT") {
77
+ field.kind = "select";
78
+ field.options = [...el.options].filter(o => !o.value.startsWith("resumator_no_selection")
79
+ && !/^no answer$/i.test(o.text.trim())).map(o => o.text.trim());
80
+ } else if (el.type === "file") {
81
+ field.kind = "file";
82
+ field.label = text(groupLabel);
83
+ field.required = starred(groupLabel);
84
+ } else {
85
+ field.kind = el.classList.contains("resumator-datepicker") ? "date"
86
+ : el.type === "tel" ? "tel" : "text";
87
+ if (!own && el.placeholder) field.label = el.placeholder; // city, state, postal
88
+ if (el.closest("#resumator-address")) field.required = starred(groupLabel);
89
+ field.maxLength = el.maxLength > 0 ? el.maxLength : null;
90
+ }
91
+ if (el.closest(disability) && field.kind !== "radio")
92
+ field.label = `Disability status, ${field.label}`;
93
+ found.push(field);
94
+ }
95
+ return found;
96
+ }"""
97
+
98
+ # What each field holds: an option's label for a select or radios, the checked boxes' values,
99
+ # the attached file's name.
100
+ _READ_BACK = """(form, fields) => {
101
+ const text = el => el ? el.textContent.replace(/\\s+/g, " ").trim() : null;
102
+ const out = {};
103
+ for (const [id, [kind, selector]] of Object.entries(fields)) {
104
+ if (kind === "radio") {
105
+ const checked = [...form.querySelectorAll(selector)].find(r => r.checked);
106
+ out[id] = checked ? text(checked.closest("label")) : null;
107
+ continue;
108
+ }
109
+ const el = form.querySelector(selector);
110
+ if (!el) continue;
111
+ if (kind === "checkboxes") {
112
+ out[id] = [...el.closest(".form-group, .resumator-field-wrapper")
113
+ .querySelectorAll(".resumator-questionnaire-checkbox:checked")].map(b => b.value);
114
+ } else if (kind === "consent") out[id] = el.checked ? "Yes" : "No";
115
+ else if (kind === "select") out[id] = el.selectedOptions[0]?.text.trim() || null;
116
+ else if (kind === "file") out[id] = el.files.length ? el.files[0].name : null;
117
+ else out[id] = el.value;
118
+ }
119
+ return out;
120
+ }"""
121
+
122
+
123
+ class JazzHR:
124
+ name = "jazzhr"
125
+ side_effects = [SideEffect("submit", "employer", "the application reaches the employer")]
126
+
127
+ def __init__(self) -> None:
128
+ self._fields: dict[str, tuple[str, str]] = {} # each field's kind and selector
129
+
130
+ def detect(self, url: str, page: Page | None) -> bool:
131
+ return _job(url) is not None
132
+
133
+ def posting(self, url: str) -> str:
134
+ job = _job(url)
135
+ return f"https://{job[0]}/apply/{job[1]}" if job else url
136
+
137
+ def read_api(self, url: str) -> list[Field] | None:
138
+ return None
139
+
140
+ def read_form(self, page: Page) -> list[Field]:
141
+ form = page.locator(FORM)
142
+ try:
143
+ form.wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
144
+ except Timeout:
145
+ raise Unsupported("posting_gone: no application form on the page") from None
146
+ _reject_cookies(page)
147
+ fields, self._fields = [], {}
148
+ for raw in form.evaluate(_READ):
149
+ fields.append(_field(raw))
150
+ self._fields[raw["id"]] = (raw["kind"], raw["selector"])
151
+ return fields
152
+
153
+ def fill(self, page: Page, answers: dict[str, Answer]) -> None:
154
+ _reject_cookies(page, wait=False)
155
+ form = page.locator(FORM)
156
+ for field_id, answer in answers.items():
157
+ values = [answer] if isinstance(answer, str) else answer
158
+ kind, selector = self._fields[field_id]
159
+ field = form.locator(selector)
160
+ match kind:
161
+ case "file": # behind "Attach resume", which shows the input
162
+ with suppress(Timeout):
163
+ attach = page.locator("#resumator-choose-upload")
164
+ if attach.is_visible():
165
+ attach.click()
166
+ field.set_input_files(values[0])
167
+ case "select":
168
+ field.select_option(label=values[0])
169
+ case "radio":
170
+ for radio in field.all():
171
+ label = radio.locator("xpath=ancestor::label[1]")
172
+ if " ".join(label.inner_text().split()) == values[0]:
173
+ radio.check()
174
+ case "checkboxes":
175
+ group = field.locator("xpath=ancestor::div[contains(@class,'form-group')][1]")
176
+ for box in group.locator(".resumator-questionnaire-checkbox").all():
177
+ box.set_checked(box.get_attribute("value") in values)
178
+ case "consent":
179
+ field.set_checked(answer == "Yes")
180
+ case "date": # jQuery UI's datepicker, set up with yy-mm-dd
181
+ field.fill(values[0])
182
+ field.press("Escape")
183
+ case _:
184
+ field.fill(values[0])
185
+
186
+ def read_back(self, page: Page) -> dict[str, Answer]:
187
+ values = page.locator(FORM).evaluate(_READ_BACK, self._fields)
188
+ return {k: v for k, v in values.items() if v not in (None, "", [])}
189
+
190
+ def submit(self, page: Page) -> str | None:
191
+ """Ticks the reCAPTCHA box, then clicks submit; the evidence is the page the form posts
192
+ to. An image challenge instead of the tick is `Blocked`."""
193
+ captcha = page.locator(CAPTCHA)
194
+ if captcha.count():
195
+ captcha.content_frame.locator("#recaptcha-anchor").click()
196
+ token = page.locator("#g-recaptcha-response")
197
+ try:
198
+ page.wait_for_function(
199
+ "el => el.value !== ''", arg=token.element_handle(), timeout=CAPTCHA_TIMEOUT_MS
200
+ )
201
+ except Timeout:
202
+ if page.locator(CHALLENGE).is_visible():
203
+ raise Blocked("captcha: reCAPTCHA image challenge") from None
204
+ raise
205
+ with page.expect_navigation(timeout=SUBMIT_TIMEOUT_MS) as posted:
206
+ browser.click(page.locator(f"{FORM} #resumator-submit-resume"))
207
+ response = posted.value
208
+ if response is None or not response.ok:
209
+ status = response.status if response else "no response"
210
+ raise RuntimeError(f"the form post answered {status}")
211
+ if page.locator(FORM).count():
212
+ raise RuntimeError("the form came back after submit")
213
+ return f"{response.url} {response.status}"
214
+
215
+
216
+ def _job(url: str) -> tuple[str, str] | None:
217
+ """The host and job code of `<account>.applytojob.com/apply/<code>[/<slug>]`."""
218
+ parts = urlsplit(url)
219
+ host = parts.hostname or ""
220
+ if not HOST.match(host):
221
+ return None
222
+ match = JOB.match(parts.path)
223
+ return (host, match[1]) if match else None
224
+
225
+
226
+ def _field(raw: dict[str, Any]) -> Field:
227
+ kind, options = raw["kind"], raw.get("options")
228
+ match kind:
229
+ case "text":
230
+ field_type = FieldType.TEXT
231
+ case "date":
232
+ field_type = FieldType.DATE
233
+ case "tel":
234
+ field_type = FieldType.PHONE
235
+ case "file":
236
+ field_type = FieldType.FILE
237
+ case "select" | "radio":
238
+ field_type = FieldType.CHOICE
239
+ case "checkboxes":
240
+ field_type = FieldType.MULTI_CHOICE
241
+ case "consent":
242
+ field_type, options = FieldType.CHOICE, YES_NO
243
+ case _:
244
+ raise Unsupported(f"form_shape: {raw['id']} is a {kind}")
245
+ accept = RESUME_TYPES if kind == "file" else None
246
+ return Field(
247
+ raw["id"], raw["label"], field_type, raw["required"], options, raw.get("maxLength"), accept
248
+ )
249
+
250
+
251
+ def _reject_cookies(page: Page, wait: bool = True) -> None:
252
+ """Rejects the tracking banner, which shows a moment after the page, over its foot."""
253
+ reject = page.locator(COOKIES)
254
+ with suppress(Timeout):
255
+ if wait:
256
+ reject.wait_for(timeout=COOKIE_TIMEOUT_MS)
257
+ if reject.is_visible():
258
+ reject.click()
@@ -0,0 +1,252 @@
1
+ """Lever (`jobs.lever.co` job pages and their application form), as docs/adapters/lever.md
2
+ observes.
3
+
4
+ The form is server-rendered HTML: every field is a named control, and its name is the field's
5
+ id. Nothing but the resume reaches Lever before submit, so the read-back is the page.
6
+ """
7
+
8
+ import re
9
+ from contextlib import suppress
10
+ from datetime import date
11
+ from typing import Any
12
+ from urllib.parse import urlsplit
13
+
14
+ from atsfill.core import browser
15
+ from atsfill.core.adapter import Blocked, Unsupported
16
+ from atsfill.core.browser import Locator, Page, Timeout
17
+ from atsfill.core.results import Answer, Field, FieldType, SideEffect
18
+
19
+ HOSTS = re.compile(r"^jobs\.(eu\.)?lever\.co$")
20
+ JOB = re.compile(r"^/([^/]+)/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b")
21
+ FORM = "form#application-form"
22
+ FORM_TIMEOUT_MS = 30_000
23
+ UPLOAD_TIMEOUT_MS = 30_000
24
+ SUGGEST_TIMEOUT_MS = 10_000
25
+ SUBMIT_TIMEOUT_MS = 90_000
26
+ YES_NO = ["Yes", "No"]
27
+ KINDS = {
28
+ "text": FieldType.TEXT,
29
+ "phone": FieldType.PHONE,
30
+ "date": FieldType.DATE,
31
+ "location": FieldType.TYPEAHEAD,
32
+ "file": FieldType.FILE,
33
+ "select": FieldType.CHOICE,
34
+ "radio": FieldType.CHOICE,
35
+ "consent": FieldType.CHOICE,
36
+ "checkboxes": FieldType.MULTI_CHOICE,
37
+ }
38
+
39
+ # The fields in page order, one per control name: its question's label, and how it renders.
40
+ _READ = """form => {
41
+ const text = el => {
42
+ if (!el) return "";
43
+ const copy = el.cloneNode(true);
44
+ copy.querySelectorAll(".required, a, .description, .eeo-option-description, ul, select")
45
+ .forEach(e => e.remove());
46
+ return copy.textContent.replace(/\\s+/g, " ").trim();
47
+ };
48
+ const optionText = input => text(input.closest("label")?.querySelector(
49
+ ".application-answer-alternative")) || input.value;
50
+ const found = new Map();
51
+ const controls = form.querySelectorAll(
52
+ "input[name]:not([type=hidden]):not([type=submit]), select[name], textarea[name]");
53
+ for (const el of controls) {
54
+ const name = el.name;
55
+ if (!name || el.id === "customPronounsTextField") continue;
56
+ const box = el.closest(".application-question, .application-additional");
57
+ if (!box) continue;
58
+ let field = found.get(name);
59
+ if (!field) {
60
+ const label = box.querySelector(".application-label .text") ||
61
+ box.querySelector(".application-label");
62
+ field = {id: name, label: text(label), required: !!box.querySelector(".required"),
63
+ options: [], count: 0};
64
+ if (el.type === "file") {
65
+ field.kind = "file";
66
+ } else if (el.tagName === "SELECT") {
67
+ field.kind = "select";
68
+ field.options = [...el.options].filter(o => o.value).map(o => o.textContent.trim());
69
+ } else if (el.type === "radio") field.kind = "radio";
70
+ else if (el.type === "checkbox") field.kind = name.startsWith("consent[") ? "consent" :
71
+ "checkboxes";
72
+ else if (name === "location") field.kind = "location";
73
+ else if (name === "phone") field.kind = "phone";
74
+ else if (el.placeholder === "MM/DD/YYYY") field.kind = "date";
75
+ else field.kind = "text";
76
+ field.maxLength = el.maxLength > 0 ? el.maxLength : null;
77
+ if (field.kind === "consent") field.label = text(el.closest("label"));
78
+ if (!field.label) field.label = el.placeholder ||
79
+ text(box.closest(".section")?.querySelector("h4"));
80
+ found.set(name, field);
81
+ }
82
+ field.required ||= el.required;
83
+ if (el.type === "radio" || el.type === "checkbox") field.options.push(optionText(el));
84
+ }
85
+ return [...found.values()];
86
+ }"""
87
+
88
+ # What each field shows: a choice its checked option's text, a select its option's text, a file
89
+ # its attached name.
90
+ _READ_BACK = """(form, kinds) => {
91
+ const optionText = input => (input.closest("label")?.querySelector(
92
+ ".application-answer-alternative")?.textContent || input.value).replace(/\\s+/g, " ").trim();
93
+ const out = {};
94
+ for (const [name, kind] of Object.entries(kinds)) {
95
+ const all = [...form.querySelectorAll(`[name="${CSS.escape(name)}"]`)]
96
+ .filter(e => e.type !== "hidden");
97
+ if (!all.length) continue;
98
+ const el = all[0];
99
+ if (kind === "file") {
100
+ out[name] = el.closest("a, .application-field")?.querySelector(".filename")
101
+ ?.textContent.trim() || null;
102
+ } else if (kind === "select") {
103
+ const option = el.selectedOptions[0];
104
+ out[name] = option && option.value ? option.textContent.trim() : null;
105
+ } else if (kind === "radio") {
106
+ const checked = all.find(e => e.checked);
107
+ out[name] = checked ? optionText(checked) : null;
108
+ } else if (kind === "checkboxes") {
109
+ out[name] = all.filter(e => e.type === "checkbox" && e.checked).map(optionText);
110
+ } else if (kind === "consent") out[name] = el.checked ? "Yes" : "No";
111
+ else out[name] = el.value;
112
+ }
113
+ return out;
114
+ }"""
115
+
116
+
117
+ class Lever:
118
+ name = "lever"
119
+ side_effects = [
120
+ SideEffect("upload", "hiring_system", "the resume is uploaded to Lever and parsed at once"),
121
+ SideEffect("submit", "employer", "the application reaches the employer"),
122
+ ]
123
+
124
+ def __init__(self) -> None:
125
+ self._kinds: dict[str, str] = {} # how the page renders each field
126
+
127
+ def detect(self, url: str, page: Page | None) -> bool:
128
+ return _job(url) is not None
129
+
130
+ def posting(self, url: str) -> str:
131
+ parts, job = urlsplit(url), _job(url)
132
+ return f"https://{parts.hostname}/{job[0]}/{job[1]}" if job else url
133
+
134
+ def read_api(self, url: str) -> list[Field] | None:
135
+ return None # Lever's postings API leaves the application's questions out
136
+
137
+ def read_form(self, page: Page) -> list[Field]:
138
+ if not urlsplit(page.url).path.rstrip("/").endswith("/apply"):
139
+ page.goto(f"{self.posting(page.url)}/apply")
140
+ form = page.locator(FORM)
141
+ try:
142
+ form.wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
143
+ except Timeout:
144
+ raise Unsupported("posting_gone: no application form on the page") from None
145
+ fields, self._kinds = [], {}
146
+ for raw in form.evaluate(_READ):
147
+ fields.append(_field(raw))
148
+ self._kinds[raw["id"]] = raw["kind"]
149
+ return fields
150
+
151
+ def fill(self, page: Page, answers: dict[str, Answer]) -> None:
152
+ # the resume is parsed into the form, so it goes first and is waited for
153
+ for name, answer in sorted(answers.items(), key=lambda a: a[0] != "resume"):
154
+ values = [answer] if isinstance(answer, str) else answer
155
+ controls = page.locator(f"{FORM} [name='{name}']:not([type=hidden])")
156
+ match self._kinds[name]:
157
+ case "file":
158
+ _attach(controls.first, values[0], resume=name == "resume")
159
+ case "location":
160
+ _locate(page, controls.first, values[0])
161
+ case "select":
162
+ controls.first.select_option(label=values[0])
163
+ case "radio" | "checkboxes":
164
+ for value in values:
165
+ option = _option(controls, value)
166
+ if option is not None:
167
+ option.check()
168
+ case "consent":
169
+ controls.first.set_checked(answer == "Yes")
170
+ case "date": # typed as its placeholder shows, MM/DD/YYYY
171
+ day = date.fromisoformat(values[0])
172
+ controls.first.fill(f"{day.month:02}/{day.day:02}/{day.year}")
173
+ case _:
174
+ controls.first.fill(values[0])
175
+
176
+ def read_back(self, page: Page) -> dict[str, Answer]:
177
+ values = page.locator(FORM).evaluate(_READ_BACK, self._kinds)
178
+ for name, kind in self._kinds.items():
179
+ if kind == "date" and (
180
+ shown := re.fullmatch(r"(\d\d)/(\d\d)/(\d{4})", values.get(name) or "")
181
+ ):
182
+ values[name] = f"{shown[3]}-{shown[1]}-{shown[2]}"
183
+ return {k: v for k, v in values.items() if v not in (None, "", [])}
184
+
185
+ def submit(self, page: Page) -> str | None:
186
+ """Clicks submit; hCaptcha checks the visitor, then the form posts and Lever shows its
187
+ thanks page, the evidence."""
188
+ browser.click(page.locator(f"{FORM} #btn-submit"))
189
+ try:
190
+ page.wait_for_url(re.compile(r"/thanks\b"), timeout=SUBMIT_TIMEOUT_MS)
191
+ except Timeout:
192
+ if page.locator("iframe[src*='hcaptcha'][title*='challenge']").is_visible():
193
+ raise Blocked("captcha: hCaptcha asked for a challenge") from None
194
+ raise
195
+ return page.url
196
+
197
+
198
+ def _job(url: str) -> tuple[str, str] | None:
199
+ """The account and posting id, from `jobs[.eu].lever.co/<account>/<id>[/apply]`."""
200
+ parts = urlsplit(url)
201
+ if not HOSTS.match(parts.hostname or ""):
202
+ return None
203
+ match = JOB.match(parts.path)
204
+ return (match[1], match[2]) if match else None
205
+
206
+
207
+ def _field(raw: dict[str, Any]) -> Field:
208
+ kind = raw["kind"]
209
+ if kind not in KINDS:
210
+ raise Unsupported(f"form_shape: {raw['id']} is a {kind}")
211
+ options = {"consent": YES_NO, "select": raw["options"]}.get(kind)
212
+ if kind in ("radio", "checkboxes"):
213
+ options = raw["options"]
214
+ return Field(
215
+ raw["id"], raw["label"], KINDS[kind], raw["required"], options, raw.get("maxLength")
216
+ )
217
+
218
+
219
+ def _option(controls: Locator, value: str) -> Locator | None:
220
+ """The radio or checkbox whose text is `value`."""
221
+ for control in controls.all():
222
+ label = control.locator("xpath=ancestor::label[1]")
223
+ shown = label.locator(".application-answer-alternative")
224
+ text = (shown.first.text_content() if shown.count() else None) or (
225
+ control.get_attribute("value") or ""
226
+ )
227
+ if " ".join(text.split()) == value:
228
+ return control
229
+ return None
230
+
231
+
232
+ def _attach(control: Locator, path: str, resume: bool) -> None:
233
+ """Attaches a file; the resume is uploaded and parsed at once, waited for until Lever says
234
+ it read it or could not."""
235
+ control.set_input_files(path)
236
+ if not resume:
237
+ return
238
+ box = control.locator("xpath=ancestor::li[1]")
239
+ with suppress(Timeout):
240
+ box.locator(".resume-upload-success, .resume-upload-failure").locator(
241
+ "visible=true"
242
+ ).first.wait_for(timeout=UPLOAD_TIMEOUT_MS)
243
+
244
+
245
+ def _locate(page: Page, control: Locator, value: str) -> None:
246
+ """Types the location and picks Lever's first suggestion: the field takes only a pick."""
247
+ control.fill("")
248
+ control.press_sequentially(value, delay=40)
249
+ suggestion = page.locator(".dropdown-results .dropdown-location").first
250
+ with suppress(Timeout):
251
+ suggestion.wait_for(timeout=SUGGEST_TIMEOUT_MS)
252
+ suggestion.click()
@@ -0,0 +1,329 @@
1
+ """Paycor Recruiting (`recruitingbypaycor.com/career/...`), as docs/adapters/paycor.md observes.
2
+
3
+ The apply page is server-rendered. Before the form it may show pages of their own: the
4
+ federal contractor's self-identification (VEVRAA) and the Yes/No qualifying questions. Each
5
+ posts its answers to the next, which carries them as hidden inputs; the form posts everything
6
+ to `ApplyJob.action` on Submit. `read_form` walks those pages to the form, `fill` walks them
7
+ again with the answers.
8
+ """
9
+
10
+ import re
11
+ from typing import Any
12
+ from urllib.parse import parse_qs, urlencode, urlsplit
13
+
14
+ from atsfill.core.adapter import Blocked, Unsupported
15
+ from atsfill.core.browser import Page
16
+ from atsfill.core.results import Answer, Field, FieldType, SideEffect
17
+
18
+ HOST = "recruitingbypaycor.com"
19
+ CAREER = f"https://{HOST}/career/"
20
+ FORM = "form#gnewtonForm[action*='ApplyJob.action']"
21
+ EEO_FORM = "form#gnewton-vevraa-form"
22
+ QUESTIONS = "div.gnewtonYes"
23
+ UPLOAD = "#uploadResumeInput"
24
+ SUBMIT = "#gnewtonForm button.gnewtonSubmitBtn"
25
+ YES_NO = ["Yes", "No"]
26
+ EEO = {"gender": "Gender", "race": "Ethnicity/Race", "veteran": "Protected veteran status"}
27
+ LABELS = {
28
+ "ccpaDisclaimerAgree": "Privacy policy acknowledgement",
29
+ "candidateTextMessagingOptIn": "Opt in to text messages",
30
+ }
31
+ KINDS = {
32
+ "text": FieldType.TEXT,
33
+ "number": FieldType.TEXT,
34
+ "phone": FieldType.PHONE,
35
+ "date": FieldType.DATE,
36
+ "select": FieldType.CHOICE,
37
+ "radio": FieldType.CHOICE,
38
+ "checkbox": FieldType.MULTI_CHOICE,
39
+ "file": FieldType.FILE,
40
+ "upload": FieldType.FILE,
41
+ }
42
+ STEPS = 6 # pages before the form: the EEO introduction, its two pages, the questions
43
+ PAGE_TIMEOUT_MS = 30_000
44
+ UPLOAD_TIMEOUT_MS = 60_000
45
+ SUBMIT_TIMEOUT_MS = 90_000
46
+
47
+ # The form's controls in page order, a radio or a checkbox group once: what `fill` and
48
+ # `read_back` work on. A control under `display: none` and the honeypot are left out.
49
+ _CONTROLS = """() => {
50
+ const form = document.querySelector('#gnewtonForm');
51
+ const text = el => (el ? el.textContent : '').replace(/\\s+/g, ' ').trim();
52
+ const hidden = el =>
53
+ el.closest('[style*="display: none"], [style*="display:none"], .nt-aux-contact');
54
+ const option = el => text(el.closest('label')?.querySelector('.radio-label'))
55
+ || text(el.closest('label')) || text(el.parentElement);
56
+ const out = [], seen = new Set();
57
+ const upload = document.querySelector('#uploadResumeInput');
58
+ if (upload) out.push({id: 'resume', label: 'Resume', kind: 'upload',
59
+ required: upload.classList.contains('gnewtonRequired'), options: null, max: null});
60
+ for (const el of form.querySelectorAll('input, select, textarea')) {
61
+ const type = el.tagName === 'INPUT' ? el.type : el.tagName.toLowerCase();
62
+ if (['hidden', 'submit', 'button'].includes(type) || !el.name || seen.has(el.name)) continue;
63
+ if (hidden(el)) continue;
64
+ seen.add(el.name);
65
+ const group = [...form.querySelectorAll(`[name="${CSS.escape(el.name)}"]`)]
66
+ .filter(e => e.type !== 'hidden' && !hidden(e));
67
+ const box = el.closest('.gnewtonQuestionInputClass')?.parentElement;
68
+ const own = el.id && !['radio', 'checkbox'].includes(type)
69
+ && document.querySelector(`label[for="${CSS.escape(el.id)}"]`);
70
+ const label = text(own) || text(box?.querySelector('.gnewtonQuestionLabelClass label'))
71
+ || el.title
72
+ || text(el.closest('.gnewtonSectionClass')?.querySelector('.gnewtonSectionTitleClass'));
73
+ let kind = type === 'textarea' ? 'text' : type;
74
+ if (type === 'tel' || el.classList.contains('gnewtonPhone')) kind = 'phone';
75
+ else if (el.name.endsWith('_answer_date')) kind = 'date';
76
+ else if (['email', 'url'].includes(type)) kind = 'text';
77
+ const options = type === 'select'
78
+ ? [...el.options].filter(o => o.value).map(o => text(o))
79
+ : ['radio', 'checkbox'].includes(type) ? group.map(option) : null;
80
+ out.push({id: el.name, label, kind, options, max: el.maxLength > 0 ? el.maxLength : null,
81
+ required: group.some(e => e.required || e.classList.contains('gnewtonRequired'))});
82
+ }
83
+ return out;
84
+ }"""
85
+ # The answer each control holds: a select's shown text, a checked radio's or boxes' labels.
86
+ _READ = """controls => {
87
+ const form = document.querySelector('#gnewtonForm');
88
+ const text = el => (el ? el.textContent : '').replace(/\\s+/g, ' ').trim();
89
+ const option = el => text(el.closest('label')?.querySelector('.radio-label'))
90
+ || text(el.closest('label')) || text(el.parentElement);
91
+ const held = {};
92
+ for (const [id, kind] of controls) {
93
+ const els = [...form.querySelectorAll(`[name="${CSS.escape(id)}"]`)]
94
+ .filter(e => e.type !== 'hidden');
95
+ const el = els[0];
96
+ let value = null;
97
+ switch (kind) {
98
+ case 'upload': value = form.querySelector('[name=ajaxResumeFilename]').value || null; break;
99
+ case 'file': value = el && el.files.length ? el.files[0].name : null; break;
100
+ case 'select': value = el.value ? text(el.selectedOptions[0]) : null; break;
101
+ case 'radio': value = els.filter(e => e.checked).map(option)[0] || null; break;
102
+ case 'checkbox': value = els.filter(e => e.checked).map(option); break;
103
+ case 'date': {
104
+ const m = el.value.match(/^(\\d{2})\\/(\\d{2})\\/(\\d{4})$/);
105
+ value = el.type === 'date' ? el.value || null : m ? `${m[3]}-${m[1]}-${m[2]}` : null;
106
+ break;
107
+ }
108
+ default: value = el.value || null;
109
+ }
110
+ held[id] = value;
111
+ }
112
+ return held;
113
+ }"""
114
+ # Clicks a radio or a box when its state differs: the styled mark can cover the input.
115
+ _CHECK = """(el, checked) => { if (el.checked !== checked) el.click(); }"""
116
+ # A hidden input of the form: an answer given on a page before it.
117
+ _HIDDEN = """name => {
118
+ const el = document.querySelector(`#gnewtonForm input[type=hidden][name="${CSS.escape(name)}"]`);
119
+ return el ? el.value : null;
120
+ }"""
121
+
122
+
123
+ def _ids(url: str) -> tuple[str, str] | None:
124
+ """The client and job ids of a Paycor career URL, or of an employer page opening it."""
125
+ query = parse_qs(urlsplit(url).query)
126
+ client, job = query.get("clientId", [""])[0], (query.get("id") or query.get("gni") or [""])[0]
127
+ return (client, job) if client and job else None
128
+
129
+
130
+ class Paycor:
131
+ name = "paycor"
132
+ side_effects = [
133
+ SideEffect("upload", "hiring_system", "the resume is stored and parsed by Paycor at once"),
134
+ SideEffect("submit", "employer", "the application reaches the employer"),
135
+ ]
136
+
137
+ def __init__(self) -> None:
138
+ self._start: str | None = None
139
+ self._before: list[tuple[Field, str]] = [] # (field, page): a field before the form
140
+ self._codes: dict[str, dict[str, str]] = {} # an EEO page's option labels, by value
141
+ self._controls: list[tuple[Field, str]] = [] # (field, kind) on the form
142
+
143
+ def detect(self, url: str, page: Page | None) -> bool:
144
+ """A `recruitingbypaycor.com/career` job URL, or an employer page that embeds one."""
145
+ self._start = None # one process detects many postings: a survey, the gate
146
+ parts = urlsplit(url)
147
+ if parts.hostname == HOST and (ids := _ids(url)):
148
+ self._start = self._apply(*ids)
149
+ elif page is not None:
150
+ frame = page.locator(f'iframe[src*="{HOST}/career/"]').first
151
+ src = frame.get_attribute("src") if frame.count() else None
152
+ job = parse_qs(parts.query).get("gni", [None])[0]
153
+ if src and (ids := _ids(src)):
154
+ self._start = self._apply(ids[0], job or ids[1])
155
+ return self._start is not None
156
+
157
+ def posting(self, url: str) -> str:
158
+ """The job id alone: it is unique across employers, and an employer's page names only it."""
159
+ query = parse_qs(urlsplit(url).query)
160
+ job = (query.get("id") or query.get("gni") or [url])[0]
161
+ return f"{CAREER}JobIntroduction.action?id={job}"
162
+
163
+ def read_api(self, url: str) -> list[Field] | None:
164
+ return None # the form is behind the pages before it, posted in a session
165
+
166
+ def read_form(self, page: Page) -> list[Field]:
167
+ self._before, self._codes = [], {}
168
+ self._walk(page, None)
169
+ self._controls = [
170
+ (
171
+ Field(
172
+ c["id"],
173
+ LABELS.get(c["id"], EEO.get(c["id"], c["label"]))
174
+ + (" (a number)" if c["kind"] == "number" else ""),
175
+ KINDS[c["kind"]],
176
+ c["required"],
177
+ c["options"],
178
+ c["max"],
179
+ ),
180
+ c["kind"],
181
+ )
182
+ for c in page.evaluate(_CONTROLS)
183
+ if c["kind"] in KINDS
184
+ ]
185
+ return [f for f, _ in self._before] + [f for f, _ in self._controls]
186
+
187
+ def fill(self, page: Page, answers: dict[str, Answer]) -> None:
188
+ if self._before:
189
+ self._walk(page, answers)
190
+ controls = sorted(self._controls, key=lambda c: c[1] != "upload") # parsed, then answered
191
+ for field, kind in controls:
192
+ if (answer := answers.get(field.id)) is None:
193
+ continue
194
+ named = page.locator(f'#gnewtonForm [name="{field.id}"]:not([type=hidden])')
195
+ match kind, answer:
196
+ case "upload", str():
197
+ page.locator(UPLOAD).set_input_files(answer)
198
+ page.wait_for_function(
199
+ "() => document.querySelector('[name=ajaxResumeFilename]').value",
200
+ timeout=UPLOAD_TIMEOUT_MS,
201
+ )
202
+ case "file", str():
203
+ named.set_input_files(answer)
204
+ case "date", str() if re.fullmatch(r"\d{4}-\d{2}-\d{2}", answer):
205
+ year, month, day = answer.split("-")
206
+ native = named.get_attribute("type") == "date" # the page's HTML5 dates
207
+ named.fill(answer if native else f"{month}/{day}/{year}")
208
+ named.dispatch_event("change")
209
+ case "text" | "number" | "phone", str():
210
+ named.fill(answer)
211
+ named.dispatch_event("change")
212
+ case "select", str() if named.is_disabled():
213
+ raise Unsupported(f"form_shape: {field.id} has no options for that country")
214
+ case "select", str():
215
+ named.select_option(label=answer)
216
+ case "radio", str():
217
+ named.first.wait_for(state="attached")
218
+ page.wait_for_function(
219
+ "n => [...document.getElementsByName(n)].some(e => !e.disabled)",
220
+ arg=field.id,
221
+ )
222
+ self._pick(named, field, [answer])
223
+ case "checkbox", list():
224
+ self._pick(named, field, answer)
225
+ case _:
226
+ raise Unsupported(
227
+ f"form_shape: cannot fill {field.id}, a {kind}, with {answer!r}"
228
+ )
229
+
230
+ def read_back(self, page: Page) -> dict[str, Answer]:
231
+ held: dict[str, Answer] = {}
232
+ for field, step in self._before:
233
+ value = page.evaluate(_HIDDEN, field.id.split(".", 1)[1])
234
+ if step == "eeo":
235
+ held[field.id] = self._codes[field.id].get(value or "", "")
236
+ elif value:
237
+ held[field.id] = "Yes" if value == "true" else "No"
238
+ held |= page.evaluate(_READ, [[f.id, kind] for f, kind in self._controls])
239
+ return {k: v for k, v in held.items() if v not in (None, [])}
240
+
241
+ def submit(self, page: Page) -> str | None:
242
+ with page.expect_response(
243
+ lambda r: r.request.method == "POST" and "ApplyJob.action" in r.url,
244
+ timeout=SUBMIT_TIMEOUT_MS,
245
+ ) as submitted:
246
+ page.locator(SUBMIT).click()
247
+ response = submitted.value
248
+ if not response.ok:
249
+ raise Blocked(f"form_errors: {response.status}")
250
+ page.wait_for_load_state()
251
+ if page.locator(FORM).count():
252
+ errors = page.locator(".gnewtonErrorMessage, .error").all_text_contents()
253
+ raise Blocked(f"form_errors: {' '.join(errors).strip()[:300]}")
254
+ return f"POST ApplyJob.action → {response.status} ({page.url})"
255
+
256
+ @staticmethod
257
+ def _apply(client: str, job: str) -> str:
258
+ """The apply page in Paycor's own layout (`m=t`), which does not send the browser on
259
+ to the employer's career page that frames it."""
260
+ return f"{CAREER}SubmitResume.action?{urlencode({'clientId': client, 'id': job, 'm': 't'})}"
261
+
262
+ @staticmethod
263
+ def _pick(named: Any, field: Field, labels: list[str]) -> None:
264
+ for n, option in enumerate(field.options or []):
265
+ named.nth(n).evaluate(_CHECK, option in labels)
266
+
267
+ def _walk(self, page: Page, answers: dict[str, Answer] | None) -> None:
268
+ """Opens the apply page and answers the pages before the form: the persona's picks
269
+ when filling, the last option and Yes when only reading, which list the fields."""
270
+ if self._start is None:
271
+ raise Unsupported("form_shape: no Paycor job in the page")
272
+ page.goto(self._start, wait_until="domcontentloaded")
273
+ for _ in range(STEPS):
274
+ page.wait_for_load_state("domcontentloaded")
275
+ if page.locator(FORM).count():
276
+ # The page's script fills the state and country lists once its DOM is in.
277
+ page.wait_for_function(
278
+ "() => [...document.querySelectorAll('#gnewtonForm select')]"
279
+ ".every(s => s.options.length)",
280
+ timeout=PAGE_TIMEOUT_MS,
281
+ )
282
+ return
283
+ if page.locator(EEO_FORM).count():
284
+ self._eeo(page, answers)
285
+ button = page.locator(f"{EEO_FORM} #gnewton-submit")
286
+ elif page.locator(QUESTIONS).count():
287
+ self._questions(page, answers)
288
+ button = page.locator("#saveBtn")
289
+ elif "no longer active" in (page.text_content("body") or ""):
290
+ raise Unsupported("posting_gone: this job is no longer active")
291
+ else:
292
+ raise Unsupported(f"form_shape: an unknown page before the form ({page.url})")
293
+ # The form's third-party scripts (Dropbox, Google) can hold its `load` back.
294
+ with page.expect_navigation(wait_until="domcontentloaded", timeout=PAGE_TIMEOUT_MS):
295
+ button.click(no_wait_after=True)
296
+ raise Unsupported("form_shape: no form after the pages before it")
297
+
298
+ def _eeo(self, page: Page, answers: dict[str, Answer] | None) -> None:
299
+ for name in page.locator(f"{EEO_FORM} input[type=radio]").evaluate_all(
300
+ "es => [...new Set(es.map(e => e.name))]"
301
+ ):
302
+ radios = page.locator(f'{EEO_FORM} input[type=radio][name="{name}"]')
303
+ codes = radios.evaluate_all(
304
+ "es => es.map(e => [e.value, (e.closest('label')?.querySelector('.radio-label')"
305
+ " || e.closest('label') || e.parentElement)"
306
+ ".textContent.replace(/\\s+/g, ' ').trim()])"
307
+ )
308
+ fid = f"eeo.{name}"
309
+ field = Field(fid, EEO.get(name, name), FieldType.CHOICE, True, [t for _, t in codes])
310
+ if answers is None:
311
+ self._before.append((field, "eeo"))
312
+ self._codes[fid] = dict(codes)
313
+ pick = answers.get(fid) if answers is not None else codes[-1][1]
314
+ n = next((i for i, (_, t) in enumerate(codes) if t == pick), None)
315
+ if n is None:
316
+ raise Unsupported(f"missing_answer: {fid} comes before the form")
317
+ radios.nth(n).evaluate(_CHECK, True)
318
+
319
+ def _questions(self, page: Page, answers: dict[str, Answer] | None) -> None:
320
+ for question in page.locator(".gnewtonQuestionWrapper").all():
321
+ qid = (question.locator(QUESTIONS).get_attribute("id") or "")[2:]
322
+ fid = f"question.{qid}"
323
+ label = (question.locator(".gnewtonQuestion").text_content() or "").strip()
324
+ if answers is None:
325
+ self._before.append((Field(fid, label, FieldType.CHOICE, True, YES_NO), "question"))
326
+ pick = answers.get(fid) if answers is not None else "Yes"
327
+ if pick not in YES_NO:
328
+ raise Unsupported(f"missing_answer: {fid} comes before the form")
329
+ question.locator(f"#{'y' if pick == 'Yes' else 'n'}_{qid}").click()
@@ -45,7 +45,13 @@ def _opened(
45
45
  yield _login_required(url, adapter)
46
46
  return
47
47
  with open_page(data, session) as page:
48
- page.goto(url)
48
+ try:
49
+ page.goto(url)
50
+ except browser.Timeout:
51
+ if adapter is None:
52
+ raise
53
+ # A page that never finishes loading (a redirect to an employer's career page)
54
+ # is left to the adapter that claimed its URL.
49
55
  if adapter is None and (adapter := _detect(url, page)):
50
56
  detected.append(adapter.name)
51
57
  if adapter is None:
@@ -185,14 +185,18 @@ kind = "ats"
185
185
  share = 0.0118
186
186
  account = "none"
187
187
  hosting = ["hosted"]
188
+ adapter = "jazzhr"
188
189
  mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
190
+ dry_run_verified = { date = 2026-10-20, version = "0.0.3", by = "gate", commit = "2cf848b" }
189
191
 
190
192
  [lever]
191
193
  kind = "ats"
192
194
  share = 0.0116
193
195
  account = "none"
194
196
  hosting = ["hosted"]
197
+ adapter = "lever"
195
198
  mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
199
+ dry_run_verified = { date = 2026-10-20, version = "0.0.3", by = "gate", commit = "d35da0b" }
196
200
 
197
201
  [adp]
198
202
  kind = "ats"
@@ -219,7 +223,9 @@ kind = "ats"
219
223
  share = 0.0095
220
224
  account = "none"
221
225
  hosting = ["hosted", "embedded"]
226
+ adapter = "paycor"
222
227
  mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
228
+ dry_run_verified = { date = 2026-10-20, version = "0.0.3", by = "gate", commit = "98b626f" }
223
229
 
224
230
  [zohorecruit]
225
231
  kind = "ats"
File without changes