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.
Files changed (33) hide show
  1. {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/PKG-INFO +2 -2
  2. {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/README.md +1 -1
  3. {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/pyproject.toml +1 -1
  4. {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/adapters/__init__.py +6 -0
  6. atsfill-0.0.4.dev32/src/atsfill/adapters/hireology.py +328 -0
  7. atsfill-0.0.4.dev32/src/atsfill/adapters/jazzhr.py +258 -0
  8. atsfill-0.0.4.dev32/src/atsfill/adapters/paycor.py +329 -0
  9. {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/core/commands.py +7 -1
  10. {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/registry.toml +6 -0
  11. {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/LICENSE +0 -0
  12. {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/__init__.py +0 -0
  13. {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/adapters/ashby.py +0 -0
  14. {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/adapters/greenhouse.py +0 -0
  15. {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/adapters/lever.py +0 -0
  16. {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/adapters/manatal.py +0 -0
  17. {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/adapters/oracle.py +0 -0
  18. {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/adapters/paylocity.py +0 -0
  19. {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/adapters/smartrecruiters.py +0 -0
  20. {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/adapters/teamtailor.py +0 -0
  21. {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/adapters/workable.py +0 -0
  22. {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/adapters/wttj.py +0 -0
  23. {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/cli.py +0 -0
  24. {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/core/__init__.py +0 -0
  25. {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/core/accounts.py +0 -0
  26. {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/core/adapter.py +0 -0
  27. {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/core/attempts.py +0 -0
  28. {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/core/browser.py +0 -0
  29. {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/core/matching.py +0 -0
  30. {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/core/paths.py +0 -0
  31. {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/core/registry.py +0 -0
  32. {atsfill-0.0.4.dev29 → atsfill-0.0.4.dev32}/src/atsfill/core/results.py +0 -0
  33. {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.dev29
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
- ![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) ![lever: dry run verified 2026-10-20](https://img.shields.io/badge/lever-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)
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) ![hireology: dry run verified 2026-10-20](https://img.shields.io/badge/hireology-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) ![lever: dry run verified 2026-10-20](https://img.shields.io/badge/lever-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)
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) ![hireology: dry run verified 2026-10-20](https://img.shields.io/badge/hireology-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.dev29"
3
+ version = "0.0.4.dev32"
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.dev29"
3
+ version = "0.0.4.dev32"
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,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
- 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,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