atsfill 0.0.4.dev29__tar.gz → 0.0.4.dev32__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.dev32}/PKG-INFO +2 -2
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/README.md +1 -1
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/adapters/__init__.py +6 -0
- atsfill-0.0.4.dev32/src/atsfill/adapters/hireology.py +328 -0
- atsfill-0.0.4.dev32/src/atsfill/adapters/jazzhr.py +258 -0
- atsfill-0.0.4.dev32/src/atsfill/adapters/paycor.py +329 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/core/commands.py +7 -1
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/registry.toml +6 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/LICENSE +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/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.dev32
|
|
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,11 @@
|
|
|
1
1
|
from atsfill.adapters.ashby import Ashby
|
|
2
2
|
from atsfill.adapters.greenhouse import Greenhouse
|
|
3
|
+
from atsfill.adapters.hireology import Hireology
|
|
4
|
+
from atsfill.adapters.jazzhr import JazzHR
|
|
3
5
|
from atsfill.adapters.lever import Lever
|
|
4
6
|
from atsfill.adapters.manatal import Manatal
|
|
5
7
|
from atsfill.adapters.oracle import Oracle
|
|
8
|
+
from atsfill.adapters.paycor import Paycor
|
|
6
9
|
from atsfill.adapters.paylocity import Paylocity
|
|
7
10
|
from atsfill.adapters.smartrecruiters import SmartRecruiters
|
|
8
11
|
from atsfill.adapters.teamtailor import Teamtailor
|
|
@@ -20,5 +23,8 @@ ADAPTERS: list[Adapter] = [
|
|
|
20
23
|
Manatal(),
|
|
21
24
|
Paylocity(),
|
|
22
25
|
Workable(),
|
|
26
|
+
Hireology(),
|
|
27
|
+
Paycor(),
|
|
28
|
+
JazzHR(),
|
|
23
29
|
Lever(),
|
|
24
30
|
]
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Hireology (`careers.hireology.com/<careers site>/<job>/description`), as
|
|
2
|
+
docs/adapters/hireology.md observes.
|
|
3
|
+
|
|
4
|
+
The job page renders its application form from a public form API, which lists every field by
|
|
5
|
+
id, type and requirement. `read_api` fetches it over HTTP, `read_form` through the page's own
|
|
6
|
+
browser context, and both list the fields from it. Each field renders as one control whose id is
|
|
7
|
+
`<field id>-<n>`; nothing but the submit reaches Hireology.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import urllib.request
|
|
13
|
+
from contextlib import suppress
|
|
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 Page, Timeout
|
|
20
|
+
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
21
|
+
|
|
22
|
+
HOST = "careers.hireology.com"
|
|
23
|
+
JOB = re.compile(r"^/[^/]+/(\d+)(?:/|$)")
|
|
24
|
+
API = "https://api.hireology.com/v2/public/application_forms/{job}"
|
|
25
|
+
APPLIED = re.compile(r"^https://api\.hireology\.com/v2/public/")
|
|
26
|
+
FORM = "#application"
|
|
27
|
+
SUBMIT = f"{FORM} #submit"
|
|
28
|
+
FORM_TIMEOUT_MS = 30_000
|
|
29
|
+
SUBMIT_TIMEOUT_MS = 90_000
|
|
30
|
+
SHOWN_TIMEOUT_MS = 2_000
|
|
31
|
+
YES_NO = ["Yes", "No"]
|
|
32
|
+
FILE_TYPES = [".pdf", ".doc", ".docx"]
|
|
33
|
+
MONTH = " (MM/YYYY)"
|
|
34
|
+
|
|
35
|
+
# The page's labels of the fields the API names without one; a custom question carries its own.
|
|
36
|
+
LABELS = {
|
|
37
|
+
"first_name": "First name",
|
|
38
|
+
"last_name": "Last name",
|
|
39
|
+
"email_address": "Email address",
|
|
40
|
+
"home_phone": "Phone number",
|
|
41
|
+
"sms_opt_in": "I would like to communicate with the hiring team via text message",
|
|
42
|
+
"street_address": "Address",
|
|
43
|
+
"city": "City",
|
|
44
|
+
"state_id": "State/Province",
|
|
45
|
+
"zip_code": "Zip/Postal Code",
|
|
46
|
+
"linkedin": "LinkedIn URL",
|
|
47
|
+
"candidate_referred": "Were you referred by anyone?",
|
|
48
|
+
"referred_by": "Name of person who referred you",
|
|
49
|
+
"resume": "Resume",
|
|
50
|
+
"cover": "Cover letter",
|
|
51
|
+
"desired_compensation": "Desired compensation",
|
|
52
|
+
"desired_compensation_unit": "Desired compensation type",
|
|
53
|
+
"start_new_position": "Timeframe to start new position",
|
|
54
|
+
"earliest_availability": "What is your earliest available start date?",
|
|
55
|
+
"career_objection": "What are your career objectives over the next 10 years?",
|
|
56
|
+
"non_complete_in_place": "Do you have a non-compete agreement in place with your current"
|
|
57
|
+
" employer?",
|
|
58
|
+
"non_complete_in_place_reason": "Non-compete agreement details",
|
|
59
|
+
"relocate": "Are you willing to relocate?",
|
|
60
|
+
"relocate_reason": "Relocation details",
|
|
61
|
+
"company_name": "Company name",
|
|
62
|
+
"company_website": "Company website",
|
|
63
|
+
"location_city": "City",
|
|
64
|
+
"location_state_id": "State",
|
|
65
|
+
"job_title_held": "Job title held",
|
|
66
|
+
"start_date": "Start date",
|
|
67
|
+
"end_date": "End date",
|
|
68
|
+
"current": "Current",
|
|
69
|
+
"supervisor_name": "Name of immediate supervisor",
|
|
70
|
+
"supervisor_title": "Supervisor's title",
|
|
71
|
+
"manage_others": "Did you manage others in this role?",
|
|
72
|
+
"manage_others_text": "If yes, please explain",
|
|
73
|
+
"position_likeness": "What did you like most about the position?",
|
|
74
|
+
"position_dislikeness": "What did you like least about this position and why did you leave"
|
|
75
|
+
" (or plan to leave)?",
|
|
76
|
+
"highest_grade": "Highest level of education completed",
|
|
77
|
+
"institute_name": "School name",
|
|
78
|
+
"graduation_year": "Graduation year",
|
|
79
|
+
"education_city": "City",
|
|
80
|
+
"education_state_id": "State",
|
|
81
|
+
"participation_extra_activities": "Did you participate in extracurricular activities?",
|
|
82
|
+
"extra_activities": "Extracurricular activities",
|
|
83
|
+
"part_time": "Did you work while in school?",
|
|
84
|
+
"part_time_text": "Work while in school",
|
|
85
|
+
"gained_academic_awards": "Did you receive academic honors or awards?",
|
|
86
|
+
"academic_awards": "Academic honors or awards",
|
|
87
|
+
"military": "Did you serve in any branch of the US Armed Services?",
|
|
88
|
+
"army_branch": "Branch",
|
|
89
|
+
"highest_rank": "Highest rank/grade",
|
|
90
|
+
"joining_date": "Date entered",
|
|
91
|
+
"discharge_date": "Date discharged",
|
|
92
|
+
"currently_serving": "Currently serving",
|
|
93
|
+
"honorable_discharge": "Did you receive an honorable discharge?",
|
|
94
|
+
"nature_of_duty": "What was the nature of your duties?",
|
|
95
|
+
"gender": "Gender",
|
|
96
|
+
"is_hispanic": "Are you Hispanic or Latino?",
|
|
97
|
+
"race": "Identify your race",
|
|
98
|
+
"veteran_status": "Veteran status",
|
|
99
|
+
"signature": "Signature",
|
|
100
|
+
}
|
|
101
|
+
# The page's heading of a section whose labels repeat others' ("City").
|
|
102
|
+
SECTIONS = {"experience": "Work history: ", "education": "Education: ", "military": "Military: "}
|
|
103
|
+
# A field the page shows only once another is answered: (that field, the answers that show it).
|
|
104
|
+
SHOWN_BY = {
|
|
105
|
+
"referred_by": ("candidate_referred", "is Yes"),
|
|
106
|
+
"non_complete_in_place_reason": ("non_complete_in_place", "is Yes"),
|
|
107
|
+
"relocate_reason": ("relocate", "is Yes"),
|
|
108
|
+
"extra_activities": ("participation_extra_activities", "is Yes"),
|
|
109
|
+
"part_time_text": ("part_time", "is Yes"),
|
|
110
|
+
"academic_awards": ("gained_academic_awards", "is Yes"),
|
|
111
|
+
**{
|
|
112
|
+
f: ("military", "is Yes")
|
|
113
|
+
for f in (
|
|
114
|
+
"army_branch",
|
|
115
|
+
"highest_rank",
|
|
116
|
+
"joining_date",
|
|
117
|
+
"discharge_date",
|
|
118
|
+
"currently_serving",
|
|
119
|
+
"honorable_discharge",
|
|
120
|
+
"nature_of_duty",
|
|
121
|
+
)
|
|
122
|
+
},
|
|
123
|
+
**{
|
|
124
|
+
f: ("highest_grade", "is not Did Not Complete High School")
|
|
125
|
+
for f in ("institute_name", "graduation_year", "education_city", "education_state_id")
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
TYPES = {
|
|
129
|
+
"text": FieldType.TEXT,
|
|
130
|
+
"email": FieldType.TEXT,
|
|
131
|
+
"textarea": FieldType.TEXT,
|
|
132
|
+
"month": FieldType.TEXT,
|
|
133
|
+
"tel": FieldType.PHONE,
|
|
134
|
+
"date": FieldType.DATE,
|
|
135
|
+
"select": FieldType.CHOICE,
|
|
136
|
+
"radio": FieldType.CHOICE,
|
|
137
|
+
"checkbox": FieldType.CHOICE,
|
|
138
|
+
"file": FieldType.FILE,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
# The control a field renders as: the element whose id is `<field id>-<n>`, a radio group's
|
|
142
|
+
# and a month's wrapper included; `n` is 0, or 1 for the education entry.
|
|
143
|
+
_CONTROL = """(form, id) => {
|
|
144
|
+
const own = new RegExp(`^${id.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\\\$&")}-\\\\d+$`);
|
|
145
|
+
return [...form.querySelectorAll("[id]")].find(e => own.test(e.id))?.id ?? null;
|
|
146
|
+
}"""
|
|
147
|
+
|
|
148
|
+
# The form API's answer, fetched as the page fetches it.
|
|
149
|
+
_FETCH = """async url => {
|
|
150
|
+
const response = await fetch(url);
|
|
151
|
+
return response.ok ? {json: await response.json()} : {status: response.status};
|
|
152
|
+
}"""
|
|
153
|
+
|
|
154
|
+
# Picks a dropdown's option by its text: React keeps the value only from a change it sees.
|
|
155
|
+
_SELECT = """(el, text) => {
|
|
156
|
+
const option = [...el.options].find(o => o.text.trim() === text);
|
|
157
|
+
if (!option) return;
|
|
158
|
+
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value").set.call(el, option.value);
|
|
159
|
+
el.dispatchEvent(new Event("change", {bubbles: true}));
|
|
160
|
+
}"""
|
|
161
|
+
|
|
162
|
+
# What each present control shows, by the field's API type.
|
|
163
|
+
_READ_BACK = """(form, kinds) => {
|
|
164
|
+
const out = {};
|
|
165
|
+
for (const [id, kind] of Object.entries(kinds)) {
|
|
166
|
+
const own = new RegExp(`^${id.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\\\$&")}-\\\\d+$`);
|
|
167
|
+
const el = [...form.querySelectorAll("[id]")].find(e => own.test(e.id));
|
|
168
|
+
if (!el) continue;
|
|
169
|
+
if (kind === "radio") {
|
|
170
|
+
const checked = el.querySelector("input:checked");
|
|
171
|
+
out[id] = checked ? checked.closest("label").textContent.trim() : null;
|
|
172
|
+
} else if (kind === "checkbox") out[id] = el.checked ? "Yes" : "No";
|
|
173
|
+
else if (kind === "select") out[id] = el.value ? el.selectedOptions[0].text.trim() : null;
|
|
174
|
+
else if (kind === "month") {
|
|
175
|
+
const month = document.getElementById(`${el.id}-month`).value;
|
|
176
|
+
const year = document.getElementById(`${el.id}-year`).value;
|
|
177
|
+
out[id] = month || year ? `${month}/${year}` : null;
|
|
178
|
+
} else if (kind === "file") {
|
|
179
|
+
const shown = el.closest(".careers-file-input__container")
|
|
180
|
+
?.querySelector("[data-qa-id=careers-filename]");
|
|
181
|
+
out[id] = shown ? shown.textContent.trim() : null;
|
|
182
|
+
} else out[id] = el.value;
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
}"""
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class Hireology:
|
|
189
|
+
name = "hireology"
|
|
190
|
+
side_effects = [SideEffect("submit", "employer", "the application reaches the employer")]
|
|
191
|
+
|
|
192
|
+
def __init__(self) -> None:
|
|
193
|
+
self._kinds: dict[str, str] = {} # each field's API type, in the form's order
|
|
194
|
+
|
|
195
|
+
def detect(self, url: str, page: Page | None) -> bool:
|
|
196
|
+
return _job(url) is not None
|
|
197
|
+
|
|
198
|
+
def posting(self, url: str) -> str:
|
|
199
|
+
job = _job(url)
|
|
200
|
+
return API.format(job=job) if job else url
|
|
201
|
+
|
|
202
|
+
def read_api(self, url: str) -> list[Field] | None:
|
|
203
|
+
if (job := _job(url)) is None:
|
|
204
|
+
return None
|
|
205
|
+
request = urllib.request.Request(API.format(job=job), headers={"User-Agent": "atsfill"})
|
|
206
|
+
try:
|
|
207
|
+
with urllib.request.urlopen(request, timeout=20) as response:
|
|
208
|
+
return [f for f, _ in _form(json.load(response))]
|
|
209
|
+
except OSError, ValueError, KeyError, TypeError, Unsupported:
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
def read_form(self, page: Page) -> list[Field]:
|
|
213
|
+
job = _job(page.url)
|
|
214
|
+
try:
|
|
215
|
+
page.locator(SUBMIT).wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
|
|
216
|
+
except Timeout:
|
|
217
|
+
raise Unsupported("posting_gone: no application form on the page") from None
|
|
218
|
+
api = page.evaluate(_FETCH, API.format(job=job))
|
|
219
|
+
if "status" in api:
|
|
220
|
+
raise Unsupported(f"posting_gone: the form API answered {api['status']}")
|
|
221
|
+
form = _form(api["json"])
|
|
222
|
+
self._kinds = {field.id: kind for field, kind in form}
|
|
223
|
+
form_el = page.locator(FORM)
|
|
224
|
+
for field, _ in form:
|
|
225
|
+
if field.id not in SHOWN_BY and form_el.evaluate(_CONTROL, field.id) is None:
|
|
226
|
+
raise Unsupported(f"form_shape: {field.id} is not on the page")
|
|
227
|
+
return [f for f, _ in form]
|
|
228
|
+
|
|
229
|
+
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
230
|
+
form = page.locator(FORM)
|
|
231
|
+
for field_id, kind in self._kinds.items(): # a question before the fields it shows
|
|
232
|
+
if field_id not in answers:
|
|
233
|
+
continue
|
|
234
|
+
answer = answers[field_id]
|
|
235
|
+
value = answer if isinstance(answer, str) else answer[0]
|
|
236
|
+
if field_id in SHOWN_BY: # rendered a moment after the answer that shows it
|
|
237
|
+
with suppress(Timeout):
|
|
238
|
+
form.locator(f"[id^='{field_id}-']").first.wait_for(
|
|
239
|
+
state="attached", timeout=SHOWN_TIMEOUT_MS
|
|
240
|
+
)
|
|
241
|
+
dom = form.evaluate(_CONTROL, field_id)
|
|
242
|
+
if dom is None:
|
|
243
|
+
continue # shown by another answer, which was not given
|
|
244
|
+
control = page.locator(f"[id='{dom}']")
|
|
245
|
+
match kind:
|
|
246
|
+
case "file":
|
|
247
|
+
control.set_input_files(value)
|
|
248
|
+
case "select":
|
|
249
|
+
control.evaluate(_SELECT, value)
|
|
250
|
+
case "radio":
|
|
251
|
+
browser.click(control.locator("label").filter(has_text=_exact(value)))
|
|
252
|
+
case "checkbox":
|
|
253
|
+
if control.is_checked() != (value == "Yes"):
|
|
254
|
+
browser.click(page.locator(f"label[for='{dom}']"))
|
|
255
|
+
case "month":
|
|
256
|
+
month, _, year = value.partition("/")
|
|
257
|
+
page.locator(f"[id='{dom}-month']").fill(month)
|
|
258
|
+
page.locator(f"[id='{dom}-year']").fill(year)
|
|
259
|
+
case _:
|
|
260
|
+
control.fill(value)
|
|
261
|
+
|
|
262
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
263
|
+
values = page.locator(FORM).evaluate(_READ_BACK, self._kinds)
|
|
264
|
+
return {k: v for k, v in values.items() if v not in (None, "")}
|
|
265
|
+
|
|
266
|
+
def submit(self, page: Page) -> str | None:
|
|
267
|
+
"""Clicks submit; the evidence is the API's answer to the application."""
|
|
268
|
+
with page.expect_response(
|
|
269
|
+
lambda r: r.request.method == "POST" and APPLIED.match(r.url) is not None,
|
|
270
|
+
timeout=SUBMIT_TIMEOUT_MS,
|
|
271
|
+
) as applied:
|
|
272
|
+
browser.click(page.locator(SUBMIT))
|
|
273
|
+
response = applied.value
|
|
274
|
+
if not response.ok:
|
|
275
|
+
raise RuntimeError(f"apply answered {response.status}: {response.text()[:200]}")
|
|
276
|
+
return f"{response.url} {response.status}"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _job(url: str) -> str | None:
|
|
280
|
+
parts = urlsplit(url)
|
|
281
|
+
if parts.hostname != HOST or not (match := JOB.match(parts.path)):
|
|
282
|
+
return None
|
|
283
|
+
return match[1]
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _exact(value: str) -> re.Pattern[str]:
|
|
287
|
+
return re.compile(rf"^\s*{re.escape(value)}\s*$")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _form(api: dict[str, Any]) -> list[tuple[Field, str]]:
|
|
291
|
+
"""Each field of the form API's template with its API type; an entry the page can repeat
|
|
292
|
+
is read once, as its first."""
|
|
293
|
+
out = []
|
|
294
|
+
for section in api["template"]["sections"]:
|
|
295
|
+
for fieldset in section["fieldsets"]:
|
|
296
|
+
for entry in fieldset["fields"]:
|
|
297
|
+
out.append(
|
|
298
|
+
(_field(entry, SECTIONS.get(section["id"], "")), entry["attributes"]["type"])
|
|
299
|
+
)
|
|
300
|
+
return out
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _field(entry: dict[str, Any], section: str) -> Field:
|
|
304
|
+
field_id, attributes = entry["id"], entry["attributes"]
|
|
305
|
+
kind = attributes["type"]
|
|
306
|
+
if kind not in TYPES:
|
|
307
|
+
raise Unsupported(f"form_shape: {field_id} is a {kind}")
|
|
308
|
+
label = (
|
|
309
|
+
section
|
|
310
|
+
+ (
|
|
311
|
+
LABELS.get(field_id) or attributes.get("label") or entry.get("label") or field_id
|
|
312
|
+
).strip()
|
|
313
|
+
)
|
|
314
|
+
required = attributes["required"] and kind != "checkbox" # a box left clear is an answer
|
|
315
|
+
options = [o["name"] for o in attributes["options"]] or None
|
|
316
|
+
if kind == "checkbox":
|
|
317
|
+
options = YES_NO
|
|
318
|
+
elif kind == "month":
|
|
319
|
+
label += MONTH
|
|
320
|
+
if shown_by := SHOWN_BY.get(field_id):
|
|
321
|
+
trigger, condition = shown_by
|
|
322
|
+
label += f" (asked when {section + LABELS[trigger]!r} {condition})"
|
|
323
|
+
required = False
|
|
324
|
+
max_length = attributes.get("maxlength") if TYPES[kind] is FieldType.TEXT else None
|
|
325
|
+
if kind == "month":
|
|
326
|
+
max_length = None
|
|
327
|
+
accept = FILE_TYPES if kind == "file" else None
|
|
328
|
+
return Field(field_id, label, TYPES[kind], required, options, max_length, accept)
|
|
@@ -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"
|
|
@@ -214,14 +216,18 @@ kind = "ats"
|
|
|
214
216
|
share = 0.0101
|
|
215
217
|
account = "none"
|
|
216
218
|
hosting = ["hosted"]
|
|
219
|
+
adapter = "hireology"
|
|
217
220
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
221
|
+
dry_run_verified = { date = 2026-10-20, version = "0.0.3", by = "gate", commit = "87c2539" }
|
|
218
222
|
|
|
219
223
|
[paycor]
|
|
220
224
|
kind = "ats"
|
|
221
225
|
share = 0.0095
|
|
222
226
|
account = "none"
|
|
223
227
|
hosting = ["hosted", "embedded"]
|
|
228
|
+
adapter = "paycor"
|
|
224
229
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
230
|
+
dry_run_verified = { date = 2026-10-20, version = "0.0.3", by = "gate", commit = "98b626f" }
|
|
225
231
|
|
|
226
232
|
[zohorecruit]
|
|
227
233
|
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
|