atsfill 0.0.4.dev71__tar.gz → 0.0.4.dev73__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.dev71 → atsfill-0.0.4.dev73}/PKG-INFO +2 -2
- {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/README.md +1 -1
- {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/adapters/__init__.py +2 -0
- atsfill-0.0.4.dev73/src/atsfill/adapters/zohorecruit.py +528 -0
- {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/registry.toml +3 -1
- {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/LICENSE +0 -0
- {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/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.dev73
|
|
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
|
|
@@ -54,7 +54,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
54
54
|
## Supported hiring systems
|
|
55
55
|
|
|
56
56
|
<!-- badges -->
|
|
57
|
-
                 
|
|
57
|
+
                  
|
|
58
58
|
<!-- /badges -->
|
|
59
59
|
|
|
60
60
|
| 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 |
|
|
@@ -16,6 +16,7 @@ from atsfill.adapters.smartrecruiters import SmartRecruiters
|
|
|
16
16
|
from atsfill.adapters.teamtailor import Teamtailor
|
|
17
17
|
from atsfill.adapters.workable import Workable
|
|
18
18
|
from atsfill.adapters.wttj import WelcomeToTheJungle
|
|
19
|
+
from atsfill.adapters.zohorecruit import ZohoRecruit
|
|
19
20
|
from atsfill.core.adapter import Adapter
|
|
20
21
|
|
|
21
22
|
|
|
@@ -39,5 +40,6 @@ def adapters() -> list[Adapter]:
|
|
|
39
40
|
Recruitee(),
|
|
40
41
|
Breezy(),
|
|
41
42
|
BambooHR(),
|
|
43
|
+
ZohoRecruit(),
|
|
42
44
|
Phenom(), # last: it claims a URL by its path alone
|
|
43
45
|
]
|
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
"""Zoho Recruit (`<account>.zohorecruit.<tld>/jobs/<page>/<job>` career sites), as
|
|
2
|
+
docs/adapters/zohorecruit.md observes.
|
|
3
|
+
|
|
4
|
+
The job page names its application form, which "I'm interested" renders from a public form
|
|
5
|
+
API: sections of fields by id, type and requirement, and the pre-screening questions a form
|
|
6
|
+
may ask with them. `read_api` fetches it over HTTP, `read_form` through the page's browser
|
|
7
|
+
context, and both list the fields from it. Each field renders as a Lyte component whose id
|
|
8
|
+
derives from the field's; nothing but the submit reaches Zoho.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import urllib.request
|
|
14
|
+
from contextlib import suppress
|
|
15
|
+
from dataclasses import replace
|
|
16
|
+
from datetime import date, datetime
|
|
17
|
+
from typing import Any
|
|
18
|
+
from urllib.parse import quote, urlsplit
|
|
19
|
+
|
|
20
|
+
from atsfill.core import browser
|
|
21
|
+
from atsfill.core.adapter import Blocked, Gap, Unsupported
|
|
22
|
+
from atsfill.core.browser import HTTP_TIMEOUT_S, Page, Timeout
|
|
23
|
+
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
24
|
+
|
|
25
|
+
HOST = re.compile(r"^[\w-]+\.zohorecruit\.(?:com|eu|in|ca|com\.au|jp|com\.cn|sa|uk)$")
|
|
26
|
+
JOB = re.compile(r"^/jobs/([^/]+)/(\d+)(?:/|$)")
|
|
27
|
+
POSTING = "https://{host}/recruit/v2/public/Job_Openings/{job}"
|
|
28
|
+
API = POSTING + "/apply/{form}?pagename={page}&source=CareerSite"
|
|
29
|
+
WEBFORM = re.compile(r'"webform_id":"(\d+)"')
|
|
30
|
+
APPLY = "#detail-page-applybtn button"
|
|
31
|
+
COOKIES = "button.cookie-decline-btn"
|
|
32
|
+
FORM = "rec-form-component[webformid]"
|
|
33
|
+
SUBMIT = "#cw-submit-btn button"
|
|
34
|
+
FORM_TIMEOUT_MS = 30_000
|
|
35
|
+
COOKIE_TIMEOUT_MS = 5_000
|
|
36
|
+
COOKIE_ATTEMPTS = 3
|
|
37
|
+
SHOWN_TIMEOUT_MS = 2_000
|
|
38
|
+
TAG_TIMEOUT_MS = 2_000
|
|
39
|
+
TAG_ATTEMPTS = 4
|
|
40
|
+
SUBMIT_TIMEOUT_MS = 90_000
|
|
41
|
+
YES_NO = ["Yes", "No"]
|
|
42
|
+
NONE = "-None-"
|
|
43
|
+
|
|
44
|
+
# A field's kind, by its data type; a skill set is a textarea the page renders as tags.
|
|
45
|
+
KINDS = {
|
|
46
|
+
"text": "input",
|
|
47
|
+
"email": "input",
|
|
48
|
+
"website": "input",
|
|
49
|
+
"textarea": "textarea",
|
|
50
|
+
"integer": "number",
|
|
51
|
+
"bigint": "number",
|
|
52
|
+
"double": "number",
|
|
53
|
+
"currency": "number",
|
|
54
|
+
"phone": "phone",
|
|
55
|
+
"date": "date",
|
|
56
|
+
"picklist": "picklist",
|
|
57
|
+
"multipicklist": "multipicklist",
|
|
58
|
+
"boolean": "boolean",
|
|
59
|
+
"monthrange": "month",
|
|
60
|
+
}
|
|
61
|
+
SKILLS_UI = 600
|
|
62
|
+
NOT_FIELDS = {"captcha", "gdpr"} # the CAPTCHA is typed on submit; consent is not rendered
|
|
63
|
+
# A pre-screening question's kind, by its type.
|
|
64
|
+
QUESTIONS = {1: "input", 2: "textarea", 3: "radio", 6: "picklist"}
|
|
65
|
+
TYPES = {
|
|
66
|
+
"input": FieldType.TEXT,
|
|
67
|
+
"textarea": FieldType.TEXT,
|
|
68
|
+
"number": FieldType.TEXT,
|
|
69
|
+
"skills": FieldType.TEXT,
|
|
70
|
+
"month": FieldType.TEXT,
|
|
71
|
+
"phone": FieldType.PHONE,
|
|
72
|
+
"date": FieldType.DATE,
|
|
73
|
+
"picklist": FieldType.CHOICE,
|
|
74
|
+
"radio": FieldType.CHOICE,
|
|
75
|
+
"boolean": FieldType.CHOICE,
|
|
76
|
+
"multipicklist": FieldType.MULTI_CHOICE,
|
|
77
|
+
"file": FieldType.FILE,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# The job page and its form API, fetched as the page fetches them.
|
|
81
|
+
_FETCH = """async url => {
|
|
82
|
+
const response = await fetch(url);
|
|
83
|
+
return response.ok ? {text: await response.text()} : {status: response.status};
|
|
84
|
+
}"""
|
|
85
|
+
|
|
86
|
+
# What each present control shows: `controls` holds [field id, kind, component selector].
|
|
87
|
+
_READ_BACK = """controls => {
|
|
88
|
+
const out = {};
|
|
89
|
+
const text = el => el ? el.textContent.replace(/\\s+/g, " ").trim() : "";
|
|
90
|
+
for (const [id, kind, sel] of controls) {
|
|
91
|
+
const el = document.querySelector(sel);
|
|
92
|
+
if (!el) continue;
|
|
93
|
+
if (kind === "input" || kind === "number") out[id] = el.querySelector("input")?.value;
|
|
94
|
+
else if (kind === "textarea") out[id] = el.querySelector("textarea")?.value;
|
|
95
|
+
else if (kind === "date") {
|
|
96
|
+
out[id] = [el.querySelector("input")?.value, el.querySelector("lyte-input")
|
|
97
|
+
?.getAttribute("lt-prop-format")];
|
|
98
|
+
} else if (kind === "phone") {
|
|
99
|
+
const number = el.querySelector("input[name]")?.value;
|
|
100
|
+
const dial = text(el.querySelector(".flag-drop-code")).replace(/[()]/g, "");
|
|
101
|
+
out[id] = number ? `${dial} ${number}` : null;
|
|
102
|
+
} else if (kind === "picklist") {
|
|
103
|
+
const shown = el.querySelector("lyte-drop-button");
|
|
104
|
+
out[id] = shown?.querySelector(".lyteDropPlaceholderNormal") ? null : text(shown);
|
|
105
|
+
} else if (kind === "multipicklist") {
|
|
106
|
+
out[id] = [...el.querySelectorAll("lyte-drop-button li:not(.lyteMultiselectInput)")]
|
|
107
|
+
.map(text).filter(Boolean);
|
|
108
|
+
} else if (kind === "boolean") out[id] = el.querySelector("input").checked ? "Yes" : "No";
|
|
109
|
+
else if (kind === "radio") {
|
|
110
|
+
out[id] = text(el.querySelector("input:checked")?.closest("label")
|
|
111
|
+
?.querySelector(".lyteRadioLabel")) || null;
|
|
112
|
+
} else if (kind === "skills") {
|
|
113
|
+
out[id] = [...el.querySelectorAll("li span[aria-label]")]
|
|
114
|
+
.map(s => s.getAttribute("aria-label")).join(", ");
|
|
115
|
+
} else if (kind === "month") {
|
|
116
|
+
const month = document.getElementById(`cxdby-${el.id}_month`)
|
|
117
|
+
?.querySelector("lyte-drop-item[selected=true]")?.dataset.value;
|
|
118
|
+
const year = text(document.getElementById(`${el.id}_year`)?.querySelector(
|
|
119
|
+
"lyte-drop-button"));
|
|
120
|
+
out[id] = [month, year];
|
|
121
|
+
} else if (kind === "file") {
|
|
122
|
+
out[id] = text(el.querySelector(".lyteFileUpdList .lyteFileUpdFileName"))
|
|
123
|
+
|| text(el.querySelector(".lyteFileUpdList")) || null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}"""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class ZohoRecruit:
|
|
131
|
+
name = "zohorecruit"
|
|
132
|
+
known_gaps: dict[str, Gap] = {}
|
|
133
|
+
side_effects = [SideEffect("submit", "employer", "the application reaches the employer")]
|
|
134
|
+
|
|
135
|
+
def __init__(self) -> None:
|
|
136
|
+
self._controls: list[_Control] = [] # in the form's order
|
|
137
|
+
self._captcha = False
|
|
138
|
+
self._job: str | None = None
|
|
139
|
+
|
|
140
|
+
def detect(self, url: str, page: Page | None) -> bool:
|
|
141
|
+
return _job(url) is not None
|
|
142
|
+
|
|
143
|
+
def posting(self, url: str) -> str:
|
|
144
|
+
parts, job = urlsplit(url), _job(url)
|
|
145
|
+
return POSTING.format(host=parts.hostname, job=job[2]) if job else url
|
|
146
|
+
|
|
147
|
+
def read_api(self, url: str) -> list[Field] | None:
|
|
148
|
+
if _job(url) is None:
|
|
149
|
+
return None
|
|
150
|
+
try:
|
|
151
|
+
html = _get(url)
|
|
152
|
+
if (form := WEBFORM.search(html)) is None:
|
|
153
|
+
return None
|
|
154
|
+
api = json.loads(_get(_api(url, form[1])))
|
|
155
|
+
return [c.field for c in _form(api)[0]]
|
|
156
|
+
except OSError, ValueError, KeyError, TypeError, Unsupported:
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
def read_form(self, page: Page) -> list[Field]:
|
|
160
|
+
job = _job(page.url)
|
|
161
|
+
if job is None:
|
|
162
|
+
raise Unsupported(f"posting_gone: {page.url} is not a job page")
|
|
163
|
+
apply = page.locator(APPLY).first
|
|
164
|
+
try:
|
|
165
|
+
apply.wait_for(timeout=FORM_TIMEOUT_MS)
|
|
166
|
+
except Timeout:
|
|
167
|
+
raise Unsupported("posting_gone: no apply button on the job page") from None
|
|
168
|
+
html = page.evaluate(_FETCH, page.url)
|
|
169
|
+
if "status" in html or (form := WEBFORM.search(html["text"])) is None:
|
|
170
|
+
raise Unsupported("posting_gone: the job page names no application form")
|
|
171
|
+
_open(page, apply)
|
|
172
|
+
page.locator(SUBMIT).wait_for(timeout=FORM_TIMEOUT_MS)
|
|
173
|
+
api = page.evaluate(_FETCH, _api(page.url, form[1]))
|
|
174
|
+
if "status" in api:
|
|
175
|
+
raise Unsupported(f"posting_gone: the form API answered {api['status']}")
|
|
176
|
+
self._controls, self._captcha = _form(json.loads(api["text"]))
|
|
177
|
+
self._job = job[2]
|
|
178
|
+
for control in self._controls:
|
|
179
|
+
if control.field.unfillable or control.row or control.shown_by:
|
|
180
|
+
continue
|
|
181
|
+
if page.locator(control.sel).count() == 0:
|
|
182
|
+
reason = f"unhandled_rendering: {control.field.id} is not on the page"
|
|
183
|
+
control.field = replace(control.field, unfillable=reason)
|
|
184
|
+
fields = [c.field for c in self._controls]
|
|
185
|
+
self._controls = [c for c in self._controls if not c.field.unfillable]
|
|
186
|
+
return fields
|
|
187
|
+
|
|
188
|
+
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
189
|
+
rows = {c.row for c in self._controls if c.row and c.field.id in answers}
|
|
190
|
+
for add in rows: # one entry of each repeatable section answered
|
|
191
|
+
browser.click(page.locator(_id(add)))
|
|
192
|
+
for control in self._controls:
|
|
193
|
+
if control.field.id not in answers:
|
|
194
|
+
continue
|
|
195
|
+
el = page.locator(control.sel).first
|
|
196
|
+
if control.shown_by: # rendered once the field that shows it is answered
|
|
197
|
+
with suppress(Timeout):
|
|
198
|
+
el.wait_for(state="visible", timeout=SHOWN_TIMEOUT_MS)
|
|
199
|
+
if not el.is_visible():
|
|
200
|
+
continue
|
|
201
|
+
_fill(page, control, el, answers[control.field.id])
|
|
202
|
+
|
|
203
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
204
|
+
controls = [[c.field.id, c.kind, c.sel] for c in self._controls]
|
|
205
|
+
values = page.evaluate(_READ_BACK, controls)
|
|
206
|
+
out: dict[str, Answer] = {}
|
|
207
|
+
for control in self._controls:
|
|
208
|
+
value = values.get(control.field.id)
|
|
209
|
+
if control.kind == "date" and value:
|
|
210
|
+
value = _iso(*value)
|
|
211
|
+
elif control.kind == "month" and value:
|
|
212
|
+
month, year = value
|
|
213
|
+
value = f"{int(month):02}/{year}" if month and month != "0" else None
|
|
214
|
+
if value not in (None, "", []):
|
|
215
|
+
out[control.field.id] = value
|
|
216
|
+
return out
|
|
217
|
+
|
|
218
|
+
def submit(self, page: Page) -> str | None:
|
|
219
|
+
"""Clicks submit; the evidence is the form API's answer to the application. A form
|
|
220
|
+
with an image CAPTCHA is not sent: atsfill does not read the image."""
|
|
221
|
+
if self._captcha:
|
|
222
|
+
raise Blocked("captcha: the form asks to type an image CAPTCHA")
|
|
223
|
+
with page.expect_response(
|
|
224
|
+
lambda r: r.request.method == "POST" and f"/Job_Openings/{self._job}/apply" in r.url,
|
|
225
|
+
timeout=SUBMIT_TIMEOUT_MS,
|
|
226
|
+
) as applied:
|
|
227
|
+
browser.click(page.locator(SUBMIT))
|
|
228
|
+
response = applied.value
|
|
229
|
+
if not response.ok:
|
|
230
|
+
raise RuntimeError(f"apply answered {response.status}: {response.text()[:200]}")
|
|
231
|
+
return f"{response.url.split('?')[0]} {response.status}"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class _Control:
|
|
235
|
+
"""A field and how the page renders it: its kind, its component's selector, the button
|
|
236
|
+
that adds its section's entry, and whether another field's answer shows it."""
|
|
237
|
+
|
|
238
|
+
def __init__(
|
|
239
|
+
self, field: Field, kind: str, sel: str, row: str | None = None, shown_by: bool = False
|
|
240
|
+
) -> None:
|
|
241
|
+
self.field, self.kind, self.sel, self.row, self.shown_by = field, kind, sel, row, shown_by
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _job(url: str) -> re.Match[str] | None:
|
|
245
|
+
parts = urlsplit(url)
|
|
246
|
+
if not HOST.match(parts.hostname or ""):
|
|
247
|
+
return None
|
|
248
|
+
return JOB.match(parts.path)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _api(url: str, form: str) -> str:
|
|
252
|
+
parts, job = urlsplit(url), _job(url)
|
|
253
|
+
assert job is not None
|
|
254
|
+
return API.format(host=parts.hostname, job=job[2], form=form, page=quote(job[1]))
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _get(url: str) -> str:
|
|
258
|
+
request = urllib.request.Request(url, headers={"User-Agent": "atsfill"})
|
|
259
|
+
with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
|
|
260
|
+
return response.read().decode()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _open(page: Page, apply) -> None:
|
|
264
|
+
"""Clicks "I'm interested", declining the cookie banner first: its layer takes every
|
|
265
|
+
click while it shows, and it shows a moment after the page, at times after a while."""
|
|
266
|
+
decline = page.locator(COOKIES)
|
|
267
|
+
for attempt in range(COOKIE_ATTEMPTS):
|
|
268
|
+
with suppress(Timeout):
|
|
269
|
+
decline.wait_for(timeout=COOKIE_TIMEOUT_MS if attempt == 0 else 0)
|
|
270
|
+
decline.click()
|
|
271
|
+
try:
|
|
272
|
+
browser.click(apply)
|
|
273
|
+
return
|
|
274
|
+
except browser.Covered:
|
|
275
|
+
if attempt == COOKIE_ATTEMPTS - 1:
|
|
276
|
+
raise
|
|
277
|
+
decline.wait_for(timeout=COOKIE_TIMEOUT_MS)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _form(api: dict[str, Any]) -> tuple[list[_Control], bool]:
|
|
281
|
+
"""Each field of the form, in its order, and whether it asks for a CAPTCHA. A repeatable
|
|
282
|
+
section is read as its first entry; the pre-screening questions are read when the form
|
|
283
|
+
asks them with the application."""
|
|
284
|
+
webform = api["webforms"][0]
|
|
285
|
+
shown = {
|
|
286
|
+
f["api_name"]: (rule["primary_field"]["api_name"], query["criteria"])
|
|
287
|
+
for rule in api["meta"].get("layout_rules") or []
|
|
288
|
+
if rule.get("active", True)
|
|
289
|
+
for query in rule["queries"]
|
|
290
|
+
for action in query["actions"]
|
|
291
|
+
if action["type"] == "Show_Fields"
|
|
292
|
+
for f in action["fields"]
|
|
293
|
+
}
|
|
294
|
+
labels = {
|
|
295
|
+
f["api_name"]: f["field_label"].strip()
|
|
296
|
+
for s in webform["sections"]
|
|
297
|
+
for f in s.get("fields") or []
|
|
298
|
+
}
|
|
299
|
+
controls, captcha = [], "captcha_info" in webform
|
|
300
|
+
for section in sorted(webform["sections"], key=lambda s: s["sequence_number"]):
|
|
301
|
+
if section.get("isattachment"):
|
|
302
|
+
controls += [_file(a) for a in section["attachmenttypes"]]
|
|
303
|
+
elif section.get("istabular"):
|
|
304
|
+
controls += _row(section, webform["layout"]["id"])
|
|
305
|
+
elif section.get("isVisible", True):
|
|
306
|
+
for f in section.get("fields") or []:
|
|
307
|
+
if f["data_type"] == "captcha":
|
|
308
|
+
captcha = True
|
|
309
|
+
if (control := _control(f, labels, shown)) is not None:
|
|
310
|
+
controls.append(control)
|
|
311
|
+
assessment = api.get("Assessments")
|
|
312
|
+
if webform.get("mandate_assessment") and isinstance(assessment, dict):
|
|
313
|
+
controls += [_question(q) for q in assessment.get("questions") or []]
|
|
314
|
+
return controls, captcha
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _control(
|
|
318
|
+
f: dict[str, Any], labels: dict[str, str], shown: dict[str, tuple[str, dict]]
|
|
319
|
+
) -> _Control | None:
|
|
320
|
+
kind = f["data_type"]
|
|
321
|
+
if kind in NOT_FIELDS or not f.get("visible", True):
|
|
322
|
+
return None
|
|
323
|
+
if kind not in KINDS:
|
|
324
|
+
return _unhandled(f["api_name"], f["field_label"].strip(), f.get("required"), kind)
|
|
325
|
+
kind = "skills" if f["ui_type"] == SKILLS_UI else KINDS[kind]
|
|
326
|
+
field = _field(f["api_name"], f["field_label"].strip(), kind, f.get("required", False), f)
|
|
327
|
+
if trigger := shown.get(f["api_name"]):
|
|
328
|
+
name, criteria = trigger
|
|
329
|
+
negated = "not " if criteria["comparator"] == "not_equal" else ""
|
|
330
|
+
label = labels.get(name, name)
|
|
331
|
+
field = Field(
|
|
332
|
+
field.id,
|
|
333
|
+
f"{field.label} (asked when {label!r} is {negated}{criteria['value']})",
|
|
334
|
+
field.type,
|
|
335
|
+
False,
|
|
336
|
+
field.options,
|
|
337
|
+
field.max_length,
|
|
338
|
+
)
|
|
339
|
+
return _Control(field, kind, _id(f"rec-form_{f['id']}"), shown_by=f["api_name"] in shown)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _field(field_id: str, label: str, kind: str, required: bool, f: dict[str, Any]) -> Field:
|
|
343
|
+
options = None
|
|
344
|
+
if kind in ("picklist", "multipicklist"):
|
|
345
|
+
options = [o["display_value"] for o in f["pick_list_values"] if o["actual_value"] != NONE]
|
|
346
|
+
elif kind == "boolean":
|
|
347
|
+
options, required = YES_NO, False # a box left clear is an answer
|
|
348
|
+
elif kind == "number":
|
|
349
|
+
label += " (a number)"
|
|
350
|
+
elif kind == "month":
|
|
351
|
+
label += " (MM/YYYY)"
|
|
352
|
+
elif kind == "skills":
|
|
353
|
+
label += " (comma-separated)"
|
|
354
|
+
max_length = f.get("length") if kind in ("input", "textarea") else None
|
|
355
|
+
return Field(field_id, label, TYPES[kind], bool(required), options, max_length)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _row(section: dict[str, Any], layout: str) -> list[_Control]:
|
|
359
|
+
"""The fields of a repeatable section's first entry: `<section>.<field>`, its month
|
|
360
|
+
range as a start and an end. A box ("Currently pursuing") hides the range's end once
|
|
361
|
+
ticked, so it comes first and the end is asked when it is clear."""
|
|
362
|
+
tabular = next(f for f in section["fields"] if f["data_type"] == "tabular")
|
|
363
|
+
fields = tabular.get("fields") or [f for f in section["fields"] if f is not tabular]
|
|
364
|
+
fields = [f for f in fields if f.get("visible", True)]
|
|
365
|
+
fields.sort(key=lambda f: (f["data_type"] != "boolean", f.get("sequence_number", 0)))
|
|
366
|
+
heading = section.get("display_label") or tabular["field_label"]
|
|
367
|
+
add = f"crc-tabular-add-btn-{layout}_{section['id']}"
|
|
368
|
+
label = lambda f: f"{heading}: {f['field_label'].strip()}" # noqa: E731
|
|
369
|
+
current = next((label(f) for f in fields if f["data_type"] == "boolean"), None)
|
|
370
|
+
out = []
|
|
371
|
+
for f in fields:
|
|
372
|
+
field_id, dom = f"{tabular['api_name']}.{f['api_name']}", f"{f['id']}_1"
|
|
373
|
+
if (kind := KINDS.get(f["data_type"])) is None:
|
|
374
|
+
out.append(_unhandled(field_id, label(f), f.get("required"), f["data_type"]))
|
|
375
|
+
continue
|
|
376
|
+
if kind != "month":
|
|
377
|
+
field = _field(field_id, label(f), kind, f.get("required"), f)
|
|
378
|
+
out.append(_Control(field, kind, _id(dom), add))
|
|
379
|
+
continue
|
|
380
|
+
start = _field(f"{field_id}.from", f"{label(f)} start", kind, f.get("required"), f)
|
|
381
|
+
out.append(_Control(start, kind, _id(f"{dom}_from"), add))
|
|
382
|
+
end = _field(f"{field_id}.to", f"{label(f)} end", kind, f.get("required"), f)
|
|
383
|
+
if current:
|
|
384
|
+
end = Field(end.id, f"{end.label} (asked when {current!r} is No)", end.type, False)
|
|
385
|
+
out.append(_Control(end, kind, _id(f"{dom}_to"), add, shown_by=current is not None))
|
|
386
|
+
return out
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _file(attachment: dict[str, Any]) -> _Control:
|
|
390
|
+
accept = sorted({f".{a}" for a in attachment["$accept"].split(",") if "/" not in a})
|
|
391
|
+
field = Field(
|
|
392
|
+
attachment["api_name"],
|
|
393
|
+
attachment["field_label"].strip(),
|
|
394
|
+
FieldType.FILE,
|
|
395
|
+
bool(attachment.get("required")),
|
|
396
|
+
accept=accept,
|
|
397
|
+
)
|
|
398
|
+
# The attachment's own input: the autofill uploader above the form names RESUME too
|
|
399
|
+
upload = f"lyte-fileupload:has(input[name='rec-form_{attachment['id']}_file'])"
|
|
400
|
+
return _Control(field, "file", upload)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _question(q: dict[str, Any]) -> _Control:
|
|
404
|
+
label = re.sub(r"\s+", " ", q["question"]).strip()
|
|
405
|
+
if (kind := QUESTIONS.get(q["type"])) is None:
|
|
406
|
+
what = f"pre-screening question of type {q['type']}"
|
|
407
|
+
return _unhandled(q["id"], label, q.get("ismandatory"), what)
|
|
408
|
+
options = [o["value"] for o in q["options"]] or None
|
|
409
|
+
field = Field(q["id"], label, TYPES[kind], bool(q.get("ismandatory")), options)
|
|
410
|
+
return _Control(field, kind, _id(f"rec-form_{q['id']}"))
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _unhandled(field_id: str, label: str, required: Any, what: str) -> _Control:
|
|
414
|
+
reason = f"unhandled_rendering: {field_id} is a {what}"
|
|
415
|
+
field = Field(field_id, label, FieldType.TEXT, bool(required), unfillable=reason)
|
|
416
|
+
return _Control(field, "", "") # listed, never filled
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _fill(page: Page, control: _Control, el, answer: Answer) -> None:
|
|
420
|
+
value = answer if isinstance(answer, str) else answer[0]
|
|
421
|
+
match control.kind:
|
|
422
|
+
case "input" | "number":
|
|
423
|
+
el.locator("input").first.fill(value)
|
|
424
|
+
case "textarea":
|
|
425
|
+
el.locator("textarea").first.fill(value)
|
|
426
|
+
case "picklist":
|
|
427
|
+
_pick(page, el, [value])
|
|
428
|
+
case "multipicklist":
|
|
429
|
+
_pick(page, el, [answer] if isinstance(answer, str) else answer)
|
|
430
|
+
case "radio":
|
|
431
|
+
browser.click(el.locator("label").filter(has_text=_exact(value)))
|
|
432
|
+
case "boolean":
|
|
433
|
+
if el.locator("input").is_checked() != (value == "Yes"):
|
|
434
|
+
browser.click(el.locator("label"))
|
|
435
|
+
case "date":
|
|
436
|
+
fmt = el.locator("lyte-input").get_attribute("lt-prop-format") or "MM/DD/YYYY"
|
|
437
|
+
day = date.fromisoformat(value)
|
|
438
|
+
typed = (
|
|
439
|
+
fmt.replace("YYYY", f"{day.year:04}")
|
|
440
|
+
.replace("MM", f"{day.month:02}")
|
|
441
|
+
.replace("DD", f"{day.day:02}")
|
|
442
|
+
)
|
|
443
|
+
el.locator("input").fill(typed)
|
|
444
|
+
page.keyboard.press("Escape")
|
|
445
|
+
case "phone":
|
|
446
|
+
_phone(page, el, value)
|
|
447
|
+
case "skills":
|
|
448
|
+
for skill in (s.strip() for s in value.split(",")):
|
|
449
|
+
if skill:
|
|
450
|
+
_tag(page, el, skill)
|
|
451
|
+
case "month":
|
|
452
|
+
month, _, year = value.partition("/")
|
|
453
|
+
dom = el.get_attribute("id")
|
|
454
|
+
_drop(page, f"{dom}_month", f"[data-value='{int(month)}']") # named in its language
|
|
455
|
+
_drop(page, f"{dom}_year", f":text-is('{year}')")
|
|
456
|
+
case "file":
|
|
457
|
+
el.locator("input[type=file]").set_input_files(value)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _pick(page: Page, el, values: list[str]) -> None:
|
|
461
|
+
"""Opens a dropdown and picks each value; its list renders at the end of the page."""
|
|
462
|
+
dom = el.locator("lyte-dropdown").first.get_attribute("id")
|
|
463
|
+
items = page.locator(f"lyte-drop-body[class*='parentscope_{dom}_'] lyte-drop-item")
|
|
464
|
+
for value in values:
|
|
465
|
+
browser.click(el.locator(".lyteDummyEventContainer").first)
|
|
466
|
+
browser.click(items.filter(has_text=_exact(value)).first)
|
|
467
|
+
page.keyboard.press("Escape")
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _tag(page: Page, el, skill: str) -> None:
|
|
471
|
+
"""Adds a skill's tag: Enter takes the first of the suggestions the page looks up as the
|
|
472
|
+
skill is typed, so it does nothing until they are in. A new tag opens a proficiency
|
|
473
|
+
popover, and the input, while it has the focus, a suggestions popover: both cover the
|
|
474
|
+
fields below until closed."""
|
|
475
|
+
typed, tags = el.locator("input"), el.locator("li span[aria-label]")
|
|
476
|
+
added = tags.count()
|
|
477
|
+
typed.press_sequentially(skill) # the lookup runs on keystrokes
|
|
478
|
+
for _ in range(TAG_ATTEMPTS):
|
|
479
|
+
typed.press("Enter")
|
|
480
|
+
with suppress(Timeout):
|
|
481
|
+
tags.nth(added).wait_for(timeout=TAG_TIMEOUT_MS)
|
|
482
|
+
break
|
|
483
|
+
page.keyboard.press("Escape")
|
|
484
|
+
typed.blur()
|
|
485
|
+
page.keyboard.press("Escape")
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _drop(page: Page, dom: str, item: str) -> None:
|
|
489
|
+
"""Picks the item a selector names from a month range's dropdown."""
|
|
490
|
+
browser.click(page.locator(f"[id='{dom}'] .lyteDummyEventContainer"))
|
|
491
|
+
browser.click(page.locator(f"[id='cxdby-{dom}'] lyte-drop-item{item}").first)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _phone(page: Page, el, value: str) -> None:
|
|
495
|
+
"""Picks the country whose dial code the number starts with, the longest that fits,
|
|
496
|
+
then types the rest of the number."""
|
|
497
|
+
digits = re.sub(r"\D", "", value)
|
|
498
|
+
browser.click(el.locator(".lyteDummyEventContainer").first)
|
|
499
|
+
items = page.locator(f"lyte-drop-body[class*='parentscope_{el.get_attribute('id')}_input']")
|
|
500
|
+
countries = items.locator("lyte-drop-item").evaluate_all(
|
|
501
|
+
"items => items.map(i => [i.dataset.value, i.querySelector('.zrc-cc')?.textContent])"
|
|
502
|
+
)
|
|
503
|
+
fits = [(c, re.sub(r"\D", "", d)) for c, d in countries if d]
|
|
504
|
+
fits = [(c, d) for c, d in fits if value.startswith("+") and digits.startswith(d)]
|
|
505
|
+
if not fits:
|
|
506
|
+
page.keyboard.press("Escape")
|
|
507
|
+
el.locator("input[name]").fill(digits)
|
|
508
|
+
return
|
|
509
|
+
country, dial = max(fits, key=lambda cd: len(cd[1])) # the first listed of the longest
|
|
510
|
+
browser.click(items.locator(f"lyte-drop-item[data-value='{country}']"))
|
|
511
|
+
el.locator("input[name]").fill(digits[len(dial) :])
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _iso(typed: str | None, fmt: str | None) -> str | None:
|
|
515
|
+
if not typed:
|
|
516
|
+
return None
|
|
517
|
+
pattern = (fmt or "MM/DD/YYYY").replace("YYYY", "%Y").replace("MM", "%m").replace("DD", "%d")
|
|
518
|
+
with suppress(ValueError):
|
|
519
|
+
return datetime.strptime(typed, pattern).date().isoformat()
|
|
520
|
+
return typed
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _id(dom: str) -> str:
|
|
524
|
+
return f"[id='{dom}']:not(template)" # the component, not the template it renders from
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _exact(value: str) -> re.Pattern[str]:
|
|
528
|
+
return re.compile(rf"^\s*{re.escape(value)}\s*$")
|
|
@@ -243,7 +243,9 @@ kind = "ats"
|
|
|
243
243
|
share = 0.0089
|
|
244
244
|
account = "none"
|
|
245
245
|
hosting = ["hosted"]
|
|
246
|
+
adapter = "zohorecruit"
|
|
246
247
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
248
|
+
dry_run_verified = { date = 2026-10-22, version = "0.0.3", by = "adapter_gate", commit = "4f0baa6" }
|
|
247
249
|
|
|
248
250
|
[ukgready]
|
|
249
251
|
kind = "ats"
|
|
@@ -310,7 +312,7 @@ mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25
|
|
|
310
312
|
[isolvedhire]
|
|
311
313
|
kind = "ats"
|
|
312
314
|
share = 0.0047
|
|
313
|
-
account = "
|
|
315
|
+
account = "to_read" # Apply now first verifies the email or phone with a one-time code, then creates an account on the employer's careers site; the application wizard (/apply/<job>-0.php) redirects to that site's login until then
|
|
314
316
|
hosting = ["hosted"]
|
|
315
317
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
316
318
|
|
|
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
|
|
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
|