atsfill 0.0.4.dev29__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.
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev31}/PKG-INFO +2 -2
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev31}/README.md +1 -1
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev31}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev31}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev31}/src/atsfill/adapters/__init__.py +4 -0
- atsfill-0.0.4.dev31/src/atsfill/adapters/jazzhr.py +258 -0
- atsfill-0.0.4.dev31/src/atsfill/adapters/paycor.py +329 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev31}/src/atsfill/core/commands.py +7 -1
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev31}/src/atsfill/registry.toml +4 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev31}/LICENSE +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev31}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev31}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev31}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev31}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev31}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev31}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev31}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev31}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev31}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev31}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev31}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev31}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev31}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev31}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev31}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev31}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev31}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev31}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev31}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev31}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev31}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev29 → 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.
|
|
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
|
-
         
|
|
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 |
|
|
@@ -1,8 +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
|
|
3
4
|
from atsfill.adapters.lever import Lever
|
|
4
5
|
from atsfill.adapters.manatal import Manatal
|
|
5
6
|
from atsfill.adapters.oracle import Oracle
|
|
7
|
+
from atsfill.adapters.paycor import Paycor
|
|
6
8
|
from atsfill.adapters.paylocity import Paylocity
|
|
7
9
|
from atsfill.adapters.smartrecruiters import SmartRecruiters
|
|
8
10
|
from atsfill.adapters.teamtailor import Teamtailor
|
|
@@ -20,5 +22,7 @@ ADAPTERS: list[Adapter] = [
|
|
|
20
22
|
Manatal(),
|
|
21
23
|
Paylocity(),
|
|
22
24
|
Workable(),
|
|
25
|
+
Paycor(),
|
|
26
|
+
JazzHR(),
|
|
23
27
|
Lever(),
|
|
24
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,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
|
-
|
|
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,7 +185,9 @@ 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"
|
|
@@ -221,7 +223,9 @@ kind = "ats"
|
|
|
221
223
|
share = 0.0095
|
|
222
224
|
account = "none"
|
|
223
225
|
hosting = ["hosted", "embedded"]
|
|
226
|
+
adapter = "paycor"
|
|
224
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" }
|
|
225
229
|
|
|
226
230
|
[zohorecruit]
|
|
227
231
|
kind = "ats"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|