atsfill 0.0.4.dev34__tar.gz → 0.0.4.dev37__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.dev34 → atsfill-0.0.4.dev37}/PKG-INFO +2 -2
- {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev37}/README.md +1 -1
- {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev37}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev37}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev37}/src/atsfill/adapters/__init__.py +4 -0
- atsfill-0.0.4.dev37/src/atsfill/adapters/bamboohr.py +442 -0
- atsfill-0.0.4.dev37/src/atsfill/adapters/breezy.py +403 -0
- {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev37}/src/atsfill/registry.toml +4 -0
- {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev37}/LICENSE +0 -0
- {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev37}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev37}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev37}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev37}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev37}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev37}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev37}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev37}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev37}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev37}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev37}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev37}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev37}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev37}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev37}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev37}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev37}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev37}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev37}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev37}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev37}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev37}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev37}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev37}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev37}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev37}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev37}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev34 → atsfill-0.0.4.dev37}/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.dev37
|
|
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,4 +1,6 @@
|
|
|
1
1
|
from atsfill.adapters.ashby import Ashby
|
|
2
|
+
from atsfill.adapters.bamboohr import BambooHR
|
|
3
|
+
from atsfill.adapters.breezy import Breezy
|
|
2
4
|
from atsfill.adapters.dayforce import Dayforce
|
|
3
5
|
from atsfill.adapters.greenhouse import Greenhouse
|
|
4
6
|
from atsfill.adapters.hireology import Hireology
|
|
@@ -31,4 +33,6 @@ ADAPTERS: list[Adapter] = [
|
|
|
31
33
|
Lever(),
|
|
32
34
|
Dayforce(),
|
|
33
35
|
Recruitee(),
|
|
36
|
+
Breezy(),
|
|
37
|
+
BambooHR(),
|
|
34
38
|
]
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
"""BambooHR (`<company>.bamboohr.com/careers/<job>`), as docs/adapters/bamboohr.md observes.
|
|
2
|
+
|
|
3
|
+
The job page renders its application form, behind Apply for This Job, from a public JSON
|
|
4
|
+
answer that lists every field with its label, requirement and options. `read_api` fetches it
|
|
5
|
+
over HTTP, `read_form` through the page's own browser context, and both list the fields from
|
|
6
|
+
it. Dropdowns are Fabric menus over a hidden `select`; a file is uploaded once attached.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
import urllib.request
|
|
12
|
+
from contextlib import suppress
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.parse import urlsplit
|
|
15
|
+
|
|
16
|
+
from atsfill.core import browser
|
|
17
|
+
from atsfill.core.adapter import Blocked, Unsupported
|
|
18
|
+
from atsfill.core.browser import Locator, Page, Timeout
|
|
19
|
+
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
20
|
+
|
|
21
|
+
HOST = re.compile(r"^[\w-]+\.bamboohr\.com$")
|
|
22
|
+
JOB = re.compile(r"^/careers/(\d+)(?:/|$)")
|
|
23
|
+
DETAIL = "https://{host}/careers/{job}/detail"
|
|
24
|
+
STATES = "https://{host}/ajax/get_states?country={country}"
|
|
25
|
+
FORM = "form:has(#firstName)"
|
|
26
|
+
APPLY = "button:has-text('Apply for This Job'), a:has-text('Apply for This Job')"
|
|
27
|
+
SUBMIT = "button:has-text('Submit Application')"
|
|
28
|
+
CAPTCHA = "iframe[title=reCAPTCHA]"
|
|
29
|
+
CHALLENGE = "iframe[src*='recaptcha/api2/bframe']"
|
|
30
|
+
FORM_TIMEOUT_MS = 30_000
|
|
31
|
+
UPLOAD_TIMEOUT_MS = 30_000
|
|
32
|
+
SHOWN_TIMEOUT_MS = 2_000
|
|
33
|
+
SETTLE_MS = 300 # for React to render what a response brought
|
|
34
|
+
CAPTCHA_TIMEOUT_MS = 20_000
|
|
35
|
+
SUBMIT_TIMEOUT_MS = 90_000
|
|
36
|
+
YES_NO = ["Yes", "No"]
|
|
37
|
+
FILE_TYPES = [".pdf", ".doc", ".docx", ".xls", ".xlsx", ".csv", ".txt", ".rtf"]
|
|
38
|
+
FILE_TYPES += [".jpg", ".jpeg", ".tif", ".tiff", ".gif", ".png", ".bmp"]
|
|
39
|
+
EMPTY = "–Select–"
|
|
40
|
+
DATE_PARTS = r"yyyy|mon|mm|dd"
|
|
41
|
+
MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
|
42
|
+
OTHER = "Other"
|
|
43
|
+
# The countries the page has a state list for, by id; any other country's state is typed.
|
|
44
|
+
STATE_LISTS = {"1": "United States", "2": "Canada", "3": "Australia", "222": "United Kingdom"}
|
|
45
|
+
# The veteran question is a radio, then for a veteran a multi-select of the API's kinds.
|
|
46
|
+
VETERAN = {"Decline to Answer": "declineToAnswer", "Not a Veteran": "notVeteran"}
|
|
47
|
+
|
|
48
|
+
# Each standard field: (its kind, the `name` of its control).
|
|
49
|
+
STANDARD = {
|
|
50
|
+
"firstName": ("text", "firstName"),
|
|
51
|
+
"lastName": ("text", "lastName"),
|
|
52
|
+
"email": ("text", "email"),
|
|
53
|
+
"phone": ("phone", "phone"),
|
|
54
|
+
"streetAddress": ("text", "streetAddress.value"),
|
|
55
|
+
"city": ("text", "city.value"),
|
|
56
|
+
"state": ("state", "state.value"),
|
|
57
|
+
"zip": ("text", "zip.value"),
|
|
58
|
+
"countryId": ("select", "countryId.value"),
|
|
59
|
+
"resumeFileId": ("file", "resumeFileId"),
|
|
60
|
+
"coverLetterFileId": ("file", "coverLetterFileId"),
|
|
61
|
+
"dateAvailable": ("date", None),
|
|
62
|
+
"desiredPay": ("text", "desiredPay"),
|
|
63
|
+
"websiteUrl": ("text", "websiteUrl"),
|
|
64
|
+
"linkedinUrl": ("text", "linkedinUrl"),
|
|
65
|
+
"twitterUsername": ("text", "twitterUsername"),
|
|
66
|
+
"referredBy": ("text", "referredBy"),
|
|
67
|
+
"educationLevelId": ("select", "educationLevelId"),
|
|
68
|
+
"educationInstitutionName": ("text", "educationInstitutionName"),
|
|
69
|
+
"references": ("text", "references"),
|
|
70
|
+
"genderId": ("select", "genderId"),
|
|
71
|
+
"ethnicityId": ("select", "ethnicityId"),
|
|
72
|
+
"veteranStatusId": ("veteran", "veteranStatusId"),
|
|
73
|
+
"disabilityId": ("select", "disabilityId"),
|
|
74
|
+
}
|
|
75
|
+
# A custom question's kind by its API type; its control is `customQuestionAnswers.<type>_<id>`.
|
|
76
|
+
CUSTOM = {
|
|
77
|
+
"short": "text",
|
|
78
|
+
"long": "text",
|
|
79
|
+
"yes_no": "radio",
|
|
80
|
+
"multi": "radio",
|
|
81
|
+
"checkbox": "checkbox",
|
|
82
|
+
"file": "file",
|
|
83
|
+
}
|
|
84
|
+
TYPES = {
|
|
85
|
+
"text": FieldType.TEXT,
|
|
86
|
+
"phone": FieldType.PHONE,
|
|
87
|
+
"date": FieldType.DATE,
|
|
88
|
+
"select": FieldType.CHOICE,
|
|
89
|
+
"veteran": FieldType.CHOICE,
|
|
90
|
+
"state": FieldType.CHOICE,
|
|
91
|
+
"radio": FieldType.CHOICE,
|
|
92
|
+
"checkbox": FieldType.CHOICE,
|
|
93
|
+
"file": FieldType.FILE,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
# The form's JSON answer and the state lists, fetched as the page fetches them.
|
|
97
|
+
_FETCH = """async urls => {
|
|
98
|
+
const out = [];
|
|
99
|
+
for (const url of urls) {
|
|
100
|
+
const response = await fetch(url);
|
|
101
|
+
out.push(response.ok ? {json: await response.json()} : {status: response.status});
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}"""
|
|
105
|
+
|
|
106
|
+
# What each present control shows, by the field's kind.
|
|
107
|
+
_READ_BACK = """(form, specs) => {
|
|
108
|
+
const named = name => form.querySelector(`[name="${name}"]`);
|
|
109
|
+
const shown = select => {
|
|
110
|
+
const text = select?.closest("[data-fabric-component=Select]")
|
|
111
|
+
?.querySelector("button[data-menu-id]")?.textContent.trim();
|
|
112
|
+
return text && text !== "–Select–" ? text : null;
|
|
113
|
+
};
|
|
114
|
+
const out = {};
|
|
115
|
+
for (const [id, [kind, name, extra]] of Object.entries(specs)) {
|
|
116
|
+
const el = name ? named(name) : null;
|
|
117
|
+
if (kind === "radio") {
|
|
118
|
+
const checked = form.querySelector(`input[name="${name}"]:checked`);
|
|
119
|
+
out[id] = checked ? checked.closest("label").textContent.trim() : null;
|
|
120
|
+
} else if (kind === "veteran") {
|
|
121
|
+
const checked = form.querySelector("#declineToAnswer:checked, #notVeteran:checked");
|
|
122
|
+
const kinds = el ? [...el.selectedOptions].map(o => extra[o.value]).filter(Boolean) : [];
|
|
123
|
+
out[id] = checked ? checked.closest("label").textContent.trim() : kinds[0] ?? null;
|
|
124
|
+
} else if (kind === "checkbox") out[id] = el ? (el.checked ? "Yes" : "No") : null;
|
|
125
|
+
else if (kind === "select") out[id] = shown(el);
|
|
126
|
+
else if (kind === "state") out[id] = el?.tagName === "SELECT" ? shown(el) : el?.value;
|
|
127
|
+
else if (kind === "file") {
|
|
128
|
+
const list = el?.parentElement
|
|
129
|
+
.querySelector("[data-fabric-component=FileUploadList]");
|
|
130
|
+
out[id] = el?.value && list ? list.textContent.trim() : null;
|
|
131
|
+
} else if (kind === "date") {
|
|
132
|
+
const input = form.querySelector("input[placeholder*=yyyy]");
|
|
133
|
+
out[id] = input ? [input.value, input.placeholder] : null;
|
|
134
|
+
} else out[id] = el?.value;
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}"""
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class BambooHR:
|
|
141
|
+
name = "bamboohr"
|
|
142
|
+
side_effects = [
|
|
143
|
+
SideEffect("upload", "hiring_system", "an attached file is stored by BambooHR at once"),
|
|
144
|
+
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
def __init__(self) -> None:
|
|
148
|
+
# each field's (kind, control name, extra): a radio's option ids by text, the
|
|
149
|
+
# veteran kinds' texts by id, a date's nothing
|
|
150
|
+
self._specs: dict[str, tuple[str, str | None, Any]] = {}
|
|
151
|
+
|
|
152
|
+
def detect(self, url: str, page: Page | None) -> bool:
|
|
153
|
+
return _job(url) is not None
|
|
154
|
+
|
|
155
|
+
def posting(self, url: str) -> str:
|
|
156
|
+
job = _job(url)
|
|
157
|
+
return f"https://{job[0]}/careers/{job[1]}" if job else url
|
|
158
|
+
|
|
159
|
+
def read_api(self, url: str) -> list[Field] | None:
|
|
160
|
+
if (job := _job(url)) is None:
|
|
161
|
+
return None
|
|
162
|
+
try:
|
|
163
|
+
detail = _get(DETAIL.format(host=job[0], job=job[1]))
|
|
164
|
+
states = {c: _get(STATES.format(host=job[0], country=c)) for c in STATE_LISTS}
|
|
165
|
+
return [f for f, _ in _form(detail, states, None)]
|
|
166
|
+
except OSError, ValueError, KeyError, TypeError, Unsupported:
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
def read_form(self, page: Page) -> list[Field]:
|
|
170
|
+
host, job = _job(page.url) or ("", "")
|
|
171
|
+
form = page.locator(FORM)
|
|
172
|
+
try:
|
|
173
|
+
page.locator(f"{APPLY}, {FORM}").first.wait_for(timeout=FORM_TIMEOUT_MS)
|
|
174
|
+
if not form.count():
|
|
175
|
+
browser.click(page.locator(APPLY).first)
|
|
176
|
+
form.locator("#firstName").wait_for(timeout=FORM_TIMEOUT_MS)
|
|
177
|
+
except Timeout:
|
|
178
|
+
raise Unsupported("posting_gone: no application form on the page") from None
|
|
179
|
+
urls = [DETAIL.format(host=host, job=job)]
|
|
180
|
+
urls += [STATES.format(host=host, country=c) for c in STATE_LISTS]
|
|
181
|
+
detail, *lists = page.evaluate(_FETCH, urls)
|
|
182
|
+
if "status" in detail:
|
|
183
|
+
raise Unsupported(f"posting_gone: the form's detail answered {detail['status']}")
|
|
184
|
+
states = {c: s.get("json", {}) for c, s in zip(STATE_LISTS, lists, strict=True)}
|
|
185
|
+
country = form.locator("select[name='countryId.value']")
|
|
186
|
+
default = (_shown(country) or "") if country.count() else None
|
|
187
|
+
fields = _form(detail["json"], states, default)
|
|
188
|
+
self._specs = {f.id: spec for f, spec in fields}
|
|
189
|
+
for field, (kind, name, _) in fields:
|
|
190
|
+
if field.id.endswith("_other"):
|
|
191
|
+
continue # shown once its question is answered Other
|
|
192
|
+
present = (
|
|
193
|
+
form.locator("input[placeholder*=yyyy]")
|
|
194
|
+
if kind == "date"
|
|
195
|
+
else form.locator("#veteran")
|
|
196
|
+
if kind == "veteran"
|
|
197
|
+
else form.locator(f"[name='{name}']")
|
|
198
|
+
)
|
|
199
|
+
if not present.count():
|
|
200
|
+
raise Unsupported(f"form_shape: {field.id} is not on the page")
|
|
201
|
+
return [f for f, _ in fields]
|
|
202
|
+
|
|
203
|
+
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
204
|
+
form = page.locator(FORM)
|
|
205
|
+
# the country before the state, whose list follows it
|
|
206
|
+
order = sorted(self._specs, key=lambda i: i != "countryId")
|
|
207
|
+
for field_id in order:
|
|
208
|
+
if field_id not in answers:
|
|
209
|
+
continue
|
|
210
|
+
kind, name, extra = self._specs[field_id]
|
|
211
|
+
answer = answers[field_id]
|
|
212
|
+
value = answer if isinstance(answer, str) else answer[0]
|
|
213
|
+
control = form.locator(f"[name='{name}']")
|
|
214
|
+
match kind:
|
|
215
|
+
case "file":
|
|
216
|
+
_attach(form, str(name), answer)
|
|
217
|
+
case "select" if field_id == "countryId":
|
|
218
|
+
with (
|
|
219
|
+
suppress(Timeout),
|
|
220
|
+
page.expect_response(
|
|
221
|
+
lambda r: "/ajax/get_states" in r.url, timeout=SHOWN_TIMEOUT_MS
|
|
222
|
+
),
|
|
223
|
+
): # the state's list follows the country, none when it stays
|
|
224
|
+
_pick(page, control, value)
|
|
225
|
+
page.wait_for_timeout(SETTLE_MS)
|
|
226
|
+
case "select":
|
|
227
|
+
_pick(page, control, value)
|
|
228
|
+
case "state":
|
|
229
|
+
if control.evaluate("e => e.tagName") == "SELECT":
|
|
230
|
+
_pick(page, control, value)
|
|
231
|
+
else:
|
|
232
|
+
control.fill(value)
|
|
233
|
+
case "veteran":
|
|
234
|
+
if value in VETERAN:
|
|
235
|
+
browser.click(_label(form, f"#{VETERAN[value]}"))
|
|
236
|
+
else:
|
|
237
|
+
browser.click(_label(form, "#veteran"))
|
|
238
|
+
control.wait_for(state="attached", timeout=SHOWN_TIMEOUT_MS)
|
|
239
|
+
_pick(page, control, value)
|
|
240
|
+
case "radio":
|
|
241
|
+
option = (extra or {}).get(value, value)
|
|
242
|
+
browser.click(_label(form, f"input[name='{name}'][value='{option}']"))
|
|
243
|
+
case "checkbox":
|
|
244
|
+
if control.is_checked() != (value == "Yes"):
|
|
245
|
+
browser.click(_label(form, f"[name='{name}']"))
|
|
246
|
+
case "date":
|
|
247
|
+
date = form.locator("input[placeholder*=yyyy]")
|
|
248
|
+
date.fill(_to_page(value, date.get_attribute("placeholder") or ""))
|
|
249
|
+
case _:
|
|
250
|
+
if field_id.endswith("_other"):
|
|
251
|
+
try:
|
|
252
|
+
control.wait_for(state="attached", timeout=SHOWN_TIMEOUT_MS)
|
|
253
|
+
except Timeout:
|
|
254
|
+
continue # its question was not answered Other
|
|
255
|
+
control.fill(value)
|
|
256
|
+
|
|
257
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
258
|
+
specs = {
|
|
259
|
+
i: (kind, name, extra if kind == "veteran" else None)
|
|
260
|
+
for i, (kind, name, extra) in self._specs.items()
|
|
261
|
+
}
|
|
262
|
+
values = page.locator(FORM).evaluate(_READ_BACK, specs)
|
|
263
|
+
if isinstance(date := values.get("dateAvailable"), list):
|
|
264
|
+
values["dateAvailable"] = _from_page(*date)
|
|
265
|
+
return {k: v for k, v in values.items() if v not in (None, "")}
|
|
266
|
+
|
|
267
|
+
def submit(self, page: Page) -> str | None:
|
|
268
|
+
"""Ticks the reCAPTCHA box, then clicks submit; the evidence is the answer to the
|
|
269
|
+
form's POST. An image challenge instead of the tick is `Blocked`."""
|
|
270
|
+
host = urlsplit(page.url).hostname
|
|
271
|
+
captcha = page.locator(CAPTCHA)
|
|
272
|
+
if captcha.count():
|
|
273
|
+
captcha.content_frame.locator("#recaptcha-anchor").click()
|
|
274
|
+
token = page.locator("#g-recaptcha-response")
|
|
275
|
+
try:
|
|
276
|
+
page.wait_for_function(
|
|
277
|
+
"el => el.value !== ''", arg=token.element_handle(), timeout=CAPTCHA_TIMEOUT_MS
|
|
278
|
+
)
|
|
279
|
+
except Timeout:
|
|
280
|
+
if page.locator(CHALLENGE).is_visible():
|
|
281
|
+
raise Blocked("captcha: reCAPTCHA image challenge") from None
|
|
282
|
+
raise
|
|
283
|
+
with page.expect_response(
|
|
284
|
+
lambda r: (
|
|
285
|
+
r.request.method == "POST"
|
|
286
|
+
and urlsplit(r.url).hostname == host
|
|
287
|
+
and "/ajax/files/" not in r.url
|
|
288
|
+
),
|
|
289
|
+
timeout=SUBMIT_TIMEOUT_MS,
|
|
290
|
+
) as applied:
|
|
291
|
+
browser.click(page.locator(SUBMIT))
|
|
292
|
+
response = applied.value
|
|
293
|
+
if not response.ok:
|
|
294
|
+
raise RuntimeError(f"apply answered {response.status}: {response.text()[:200]}")
|
|
295
|
+
return f"{response.url} {response.status}"
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _job(url: str) -> tuple[str, str] | None:
|
|
299
|
+
parts = urlsplit(url)
|
|
300
|
+
if not (parts.hostname and HOST.match(parts.hostname)) or not (m := JOB.match(parts.path)):
|
|
301
|
+
return None
|
|
302
|
+
return parts.hostname, m[1]
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _get(url: str) -> Any:
|
|
306
|
+
request = urllib.request.Request(url, headers={"User-Agent": "atsfill"})
|
|
307
|
+
with urllib.request.urlopen(request, timeout=20) as response:
|
|
308
|
+
return json.load(response)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _shown(select: Locator) -> str | None:
|
|
312
|
+
"""The text a Fabric select's button shows, None while nothing is picked."""
|
|
313
|
+
button = select.locator("xpath=..").locator("button[data-menu-id]")
|
|
314
|
+
text = (button.text_content() or "").strip()
|
|
315
|
+
return text if text and text != EMPTY else None
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _pick(page: Page, select: Locator, text: str) -> None:
|
|
319
|
+
"""Picks a Fabric select's option by its text from the menu its button opens."""
|
|
320
|
+
button = select.locator("xpath=..").locator("button[data-menu-id]")
|
|
321
|
+
browser.click(button)
|
|
322
|
+
menu = page.locator(f"[id='{button.get_attribute('data-menu-id')}']")
|
|
323
|
+
item = menu.locator("[role=menuitem], [role=option]").filter(has_text=_exact(text))
|
|
324
|
+
multiple = select.evaluate("e => e.multiple")
|
|
325
|
+
browser.click(item.first)
|
|
326
|
+
if multiple: # stays open for more picks
|
|
327
|
+
page.keyboard.press("Escape")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _attach(form: Locator, name: str, answer: Answer) -> None:
|
|
331
|
+
"""Attaches through the file input beside the hidden id, then waits for the upload."""
|
|
332
|
+
hidden = f"[name='{name}']"
|
|
333
|
+
form.locator(f"*:has(> {hidden}) input[type=file]").set_input_files(answer)
|
|
334
|
+
form.page.wait_for_function(
|
|
335
|
+
"el => el.value !== ''",
|
|
336
|
+
arg=form.locator(hidden).element_handle(),
|
|
337
|
+
timeout=UPLOAD_TIMEOUT_MS,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _label(form: Locator, control: str) -> Locator:
|
|
342
|
+
return form.locator(f"label:has({control})")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _exact(value: str) -> re.Pattern[str]:
|
|
346
|
+
return re.compile(rf"^\s*{re.escape(value)}\s*$")
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _to_page(iso: str, placeholder: str) -> str:
|
|
350
|
+
"""A YYYY-MM-DD date in the page's format, its placeholder (`dd mon yyyy`, `mm/dd/yyyy`)."""
|
|
351
|
+
year, month, day = iso.split("-")
|
|
352
|
+
parts = {"yyyy": year, "mon": MONTHS[int(month) - 1], "mm": month, "dd": day}
|
|
353
|
+
return re.sub(DATE_PARTS, lambda m: parts[m[0]], placeholder)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _from_page(value: str, placeholder: str) -> str:
|
|
357
|
+
"""The page's date as YYYY-MM-DD, by the placeholder's order of its parts."""
|
|
358
|
+
order, words = re.findall(DATE_PARTS, placeholder), re.findall(r"\w+", value)
|
|
359
|
+
parts = dict(zip(order, words, strict=False))
|
|
360
|
+
if parts.get("mon") in MONTHS:
|
|
361
|
+
parts["mm"] = f"{MONTHS.index(parts['mon']) + 1:02}"
|
|
362
|
+
if not {"yyyy", "mm", "dd"} <= parts.keys():
|
|
363
|
+
return value
|
|
364
|
+
return f"{parts['yyyy']}-{parts['mm']}-{parts['dd']}"
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _form(
|
|
368
|
+
detail: dict[str, Any], states: dict[str, Any], default: str | None
|
|
369
|
+
) -> list[tuple[Field, tuple[str, str | None, Any]]]:
|
|
370
|
+
"""Each field of the form's JSON answer with how to find and fill it; `default` is the
|
|
371
|
+
country the page shows before any answer (empty for none), None to take the JSON's."""
|
|
372
|
+
result = detail["result"]
|
|
373
|
+
country = (result["formFields"].get("countryId") or {}).get("value") or ""
|
|
374
|
+
if default is not None:
|
|
375
|
+
by_name = {name: c for c, name in STATE_LISTS.items()}
|
|
376
|
+
country = by_name.get(default, default)
|
|
377
|
+
if result["jobOpening"].get("jobOpeningStatus") not in (None, "Open"):
|
|
378
|
+
raise Unsupported("posting_gone: the job opening is not open")
|
|
379
|
+
out = []
|
|
380
|
+
for key, entry in result["formFields"].items():
|
|
381
|
+
if key == "customQuestions":
|
|
382
|
+
for question in entry:
|
|
383
|
+
out += _custom(question)
|
|
384
|
+
continue
|
|
385
|
+
if not entry: # a demographic question the employer does not ask
|
|
386
|
+
continue
|
|
387
|
+
if key not in STANDARD:
|
|
388
|
+
raise Unsupported(f"form_shape: {key} is a field it does not know")
|
|
389
|
+
kind, name = STANDARD[key]
|
|
390
|
+
label, required = entry["label"].strip(), entry["isRequired"]
|
|
391
|
+
options = [o["text"] for o in entry.get("options") or []] or None
|
|
392
|
+
extra: Any = None
|
|
393
|
+
prefilled = key == "countryId" and bool(country)
|
|
394
|
+
if key == "state" and options:
|
|
395
|
+
options = _states(states, country)
|
|
396
|
+
if key == "state" and not options:
|
|
397
|
+
kind = "text"
|
|
398
|
+
if kind == "veteran":
|
|
399
|
+
extra = {o["id"]: o["text"] for o in entry["options"] if int(o["id"]) > 0}
|
|
400
|
+
options = list(VETERAN) + list(extra.values())
|
|
401
|
+
accept = FILE_TYPES if kind == "file" else None
|
|
402
|
+
field = Field(key, label, TYPES[kind], required, options, None, accept, prefilled)
|
|
403
|
+
out.append((field, (kind, name, extra)))
|
|
404
|
+
return out
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _states(states: dict[str, Any], default: str | None) -> list[str]:
|
|
408
|
+
"""The state lists of every country that has one, the default country's (an id) first:
|
|
409
|
+
the list the page shows follows the country answered."""
|
|
410
|
+
order = sorted(STATE_LISTS, key=lambda c: c != default)
|
|
411
|
+
names = [s["full_name"] for c in order for s in (states.get(c) or {}).get("data") or []]
|
|
412
|
+
return list(dict.fromkeys(names))
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _custom(question: dict[str, Any]) -> list[tuple[Field, tuple[str, str | None, Any]]]:
|
|
416
|
+
qid, kind_api = question["id"], question["type"]
|
|
417
|
+
if kind_api not in CUSTOM:
|
|
418
|
+
raise Unsupported(f"form_shape: question {qid} is a {kind_api}")
|
|
419
|
+
kind = CUSTOM[kind_api]
|
|
420
|
+
name = f"customQuestionAnswers.{kind_api}_{qid}"
|
|
421
|
+
label, required = question["question"].strip(), question["isRequired"]
|
|
422
|
+
options, extra = None, None
|
|
423
|
+
if kind_api == "yes_no" or kind == "checkbox":
|
|
424
|
+
options = YES_NO
|
|
425
|
+
elif kind_api == "multi":
|
|
426
|
+
extra = {o["option"].strip(): str(o["id"]) for o in question["options"]}
|
|
427
|
+
if question.get("hasOther") == "yes":
|
|
428
|
+
extra[OTHER] = "other"
|
|
429
|
+
options = list(extra)
|
|
430
|
+
accept = FILE_TYPES if kind == "file" else None
|
|
431
|
+
field_id = f"question_{qid}"
|
|
432
|
+
field = Field(field_id, label, TYPES[kind], required, options, None, accept)
|
|
433
|
+
out = [(field, (kind, name, extra))]
|
|
434
|
+
if extra and OTHER in extra and question.get("hasOther") == "yes":
|
|
435
|
+
other = Field(
|
|
436
|
+
f"{field_id}_other",
|
|
437
|
+
f"{label}: please specify (asked when {label!r} is {OTHER})",
|
|
438
|
+
FieldType.TEXT,
|
|
439
|
+
False,
|
|
440
|
+
)
|
|
441
|
+
out.append((other, ("text", f"{name}_other", None)))
|
|
442
|
+
return out
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
"""Breezy HR (`<company>.breezy.hr/p/<position>`, or a company's own domain), as
|
|
2
|
+
docs/adapters/breezy.md observes.
|
|
3
|
+
|
|
4
|
+
Two page engines, an AngularJS one and a React one, render the same markup: named controls
|
|
5
|
+
under `h3` headings, the questionnaire's as `section_<section>_question_<n>`. The fields are
|
|
6
|
+
read from the page. Files upload to Breezy when attached; the rest reaches it on submit.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from contextlib import suppress
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
from urllib.parse import urlsplit
|
|
14
|
+
|
|
15
|
+
from atsfill.core import browser
|
|
16
|
+
from atsfill.core.adapter import Unsupported
|
|
17
|
+
from atsfill.core.browser import Locator, Page, Timeout
|
|
18
|
+
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
19
|
+
|
|
20
|
+
HOST = re.compile(r"^[a-z0-9-]+\.breezy\.hr$")
|
|
21
|
+
JOB = re.compile(r"^/p/([0-9a-f]{12,})(?:-[^/]*)?(?:/apply)?/?$")
|
|
22
|
+
PORTAL = "script[src*='assets-cdn.breezy.hr/breezy-portal']"
|
|
23
|
+
APPLIED = re.compile(r"/api/apply/[0-9a-f]+$")
|
|
24
|
+
REFERENCE = re.compile(r"^(section_\w+)\.reference(\d+)\.(\w+)$")
|
|
25
|
+
FORM = "form[name=form]"
|
|
26
|
+
NEXT = "button[ng-click='nextSection()']"
|
|
27
|
+
SUBMIT = "button[ng-click='apply()'], button[type=submit]"
|
|
28
|
+
FORM_TIMEOUT_MS = 30_000
|
|
29
|
+
UPLOAD_TIMEOUT_MS = 30_000
|
|
30
|
+
STEP_TIMEOUT_MS = 5_000
|
|
31
|
+
SUBMIT_TIMEOUT_MS = 90_000
|
|
32
|
+
MAX_STEPS = 20
|
|
33
|
+
YES_NO = ["Yes", "No"]
|
|
34
|
+
KINDS = {
|
|
35
|
+
"text": FieldType.TEXT,
|
|
36
|
+
"phone": FieldType.PHONE,
|
|
37
|
+
"date": FieldType.DATE,
|
|
38
|
+
"select": FieldType.CHOICE,
|
|
39
|
+
"radio": FieldType.CHOICE,
|
|
40
|
+
"consent": FieldType.CHOICE,
|
|
41
|
+
"checkboxes": FieldType.MULTI_CHOICE,
|
|
42
|
+
"file": FieldType.FILE,
|
|
43
|
+
}
|
|
44
|
+
# Labels of the controls without a heading of their own.
|
|
45
|
+
LABELS = {
|
|
46
|
+
"cResume": "Resume",
|
|
47
|
+
"salaryCurrency": "Desired Salary: currency",
|
|
48
|
+
"salaryPeriod": "Desired Salary: period",
|
|
49
|
+
}
|
|
50
|
+
REFERENCE_PARTS = {
|
|
51
|
+
"name": "Full Name",
|
|
52
|
+
"email_address": "Email Address",
|
|
53
|
+
"phone_number": "Phone Number",
|
|
54
|
+
"type": "Relationship",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class Entry:
|
|
60
|
+
"""A list the page adds entries to on a click; atsfill fills its first entry."""
|
|
61
|
+
|
|
62
|
+
model: str # its controls' `ng-model` object
|
|
63
|
+
add: str # what its "Add" link calls
|
|
64
|
+
heading: str
|
|
65
|
+
checked: tuple[str, ...] # what Breezy checks of the first entry when the list is required
|
|
66
|
+
fields: tuple[tuple[str, str, str], ...] # (key, label, kind)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
_DATES = (("date_start", "Start date", "date"), ("date_end", "End date", "date"))
|
|
70
|
+
ENTRIES = {
|
|
71
|
+
"work_history": Entry(
|
|
72
|
+
"candidatePosition",
|
|
73
|
+
"addPosition",
|
|
74
|
+
"Work History",
|
|
75
|
+
("company_name", "title"),
|
|
76
|
+
(
|
|
77
|
+
("company_name", "Company", "text"),
|
|
78
|
+
("title", "Title", "text"),
|
|
79
|
+
*_DATES,
|
|
80
|
+
("summary", "Summary", "text"),
|
|
81
|
+
),
|
|
82
|
+
), # fmt: skip
|
|
83
|
+
"education": Entry(
|
|
84
|
+
"candidateSchool",
|
|
85
|
+
"addEducation",
|
|
86
|
+
"Education",
|
|
87
|
+
("school_name", "field_of_study"),
|
|
88
|
+
(
|
|
89
|
+
("school_name", "School", "text"),
|
|
90
|
+
("field_of_study", "Field of Study", "text"),
|
|
91
|
+
*_DATES,
|
|
92
|
+
("summary", "Summary", "text"),
|
|
93
|
+
),
|
|
94
|
+
), # fmt: skip
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
# The fields in page order, one per control name, with its heading's text and how it renders;
|
|
98
|
+
# a question the adapter cannot fill is kept as its kind, to be refused when required.
|
|
99
|
+
_READ = """form => {
|
|
100
|
+
const text = el => {
|
|
101
|
+
if (!el) return "";
|
|
102
|
+
const copy = el.cloneNode(true);
|
|
103
|
+
copy.querySelectorAll(".required, .question-body, select, ul, .error-container")
|
|
104
|
+
.forEach(e => e.remove());
|
|
105
|
+
return copy.textContent.replace(/\\s+/g, " ").trim();
|
|
106
|
+
};
|
|
107
|
+
const starred = h => !!h && [...h.querySelectorAll(".required")]
|
|
108
|
+
.some(s => !s.classList.contains("ng-hide"));
|
|
109
|
+
const heading = el => {
|
|
110
|
+
for (let n = el; n && n !== form; n = n.parentElement)
|
|
111
|
+
for (let s = n.previousElementSibling; s; s = s.previousElementSibling) {
|
|
112
|
+
const h = s.matches("h3") ? s : s.querySelector("h3");
|
|
113
|
+
if (h) return h;
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
};
|
|
117
|
+
const optionText = el => text(el.closest("li.option, li, label"));
|
|
118
|
+
const found = new Map();
|
|
119
|
+
const add = field => {
|
|
120
|
+
if (!found.has(field.id)) found.set(field.id, field);
|
|
121
|
+
return found.get(field.id);
|
|
122
|
+
};
|
|
123
|
+
// A question whose controls are not one named group: its references, or its video.
|
|
124
|
+
const done = new Set();
|
|
125
|
+
const special = q => {
|
|
126
|
+
if (!q || done.has(q)) return done.has(q);
|
|
127
|
+
const h = q.querySelector("h3");
|
|
128
|
+
if (q.querySelector(".referencecheck")) {
|
|
129
|
+
const qid = q.querySelector("[ng-model='reference.name']")?.name;
|
|
130
|
+
const refs = [...q.querySelectorAll("li.reference")];
|
|
131
|
+
refs.forEach((ref, n) => {
|
|
132
|
+
for (const el of ref.querySelectorAll("[ng-model^='reference.']")) {
|
|
133
|
+
const part = el.getAttribute("ng-model").slice("reference.".length);
|
|
134
|
+
const kind = el.type === "radio" ? "radio" : el.type === "tel" ? "phone" : "text";
|
|
135
|
+
const f = add({id: `${qid}.reference${n + 1}.${part}`, kind, options: [],
|
|
136
|
+
question: text(h), part, n: n + 1, refs: refs.length,
|
|
137
|
+
required: el.required || starred(h)});
|
|
138
|
+
if (el.type === "radio") f.options.push(optionText(el));
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
} else if (!q.querySelector("[name^='section_']") &&
|
|
142
|
+
q.querySelector("input[type=file], .ziggeo, [ng-if*='video']")) {
|
|
143
|
+
add({id: `video_${found.size}`, label: text(h), kind: "video", required: starred(h)});
|
|
144
|
+
} else return false;
|
|
145
|
+
done.add(q);
|
|
146
|
+
return true;
|
|
147
|
+
};
|
|
148
|
+
for (const el of form.querySelectorAll("input, select, textarea")) {
|
|
149
|
+
if (["hidden", "submit", "button"].includes(el.type)) continue;
|
|
150
|
+
if (el.closest(".apply-field-extra") || special(el.closest("li.question"))) continue;
|
|
151
|
+
const model = el.getAttribute("ng-model") || "";
|
|
152
|
+
const id = el.name || (model === "candidate.salary.period" ? "salaryPeriod" : "");
|
|
153
|
+
if (!id) continue;
|
|
154
|
+
const h = heading(el);
|
|
155
|
+
let kind;
|
|
156
|
+
if (el.type === "file") kind = "file";
|
|
157
|
+
else if (el.tagName === "SELECT") kind = "select";
|
|
158
|
+
else if (el.type === "radio") kind = "radio";
|
|
159
|
+
else if (el.type === "checkbox") kind = id.startsWith("section_") ? "checkboxes" : "consent";
|
|
160
|
+
else if (el.type === "date") kind = "date";
|
|
161
|
+
else if (id === "cPhoneNumber" || el.type === "tel") kind = "phone";
|
|
162
|
+
else kind = "text";
|
|
163
|
+
const accept = el.accept ? el.accept.split(",").map(a => a.trim()).filter(Boolean) : null;
|
|
164
|
+
const field = add({id, kind, options: [], required: false, accept,
|
|
165
|
+
label: kind === "consent" ? optionText(el) : text(h),
|
|
166
|
+
maxLength: el.maxLength > 0 ? el.maxLength : null});
|
|
167
|
+
field.required ||= el.required || (kind !== "consent" && starred(h));
|
|
168
|
+
if (kind === "select")
|
|
169
|
+
field.options = [...el.options].map(o => o.textContent.trim()).filter(Boolean);
|
|
170
|
+
if (kind === "radio" || kind === "checkboxes") field.options.push(optionText(el));
|
|
171
|
+
}
|
|
172
|
+
return [...found.values()];
|
|
173
|
+
}"""
|
|
174
|
+
|
|
175
|
+
# What one field's controls show: a choice its option's text, a box Yes/No, a file the name
|
|
176
|
+
# Breezy shows once it has the upload.
|
|
177
|
+
_READ_BACK = """(controls, kind) => {
|
|
178
|
+
const el = controls[0];
|
|
179
|
+
const optionText = e => (e.closest("li.option, li, label")?.textContent || "")
|
|
180
|
+
.replace(/\\s+/g, " ").trim();
|
|
181
|
+
if (kind === "file")
|
|
182
|
+
return el.closest(".file-input-container")?.querySelector("a.bzyLinkColor:not(.placeholder)")
|
|
183
|
+
?.textContent.trim() || null;
|
|
184
|
+
if (kind === "select") {
|
|
185
|
+
const option = el.selectedOptions[0];
|
|
186
|
+
return option && option.value !== "?" && option.textContent.trim() || null;
|
|
187
|
+
}
|
|
188
|
+
if (kind === "radio") {
|
|
189
|
+
const checked = controls.find(e => e.checked);
|
|
190
|
+
return checked ? optionText(checked) : null;
|
|
191
|
+
}
|
|
192
|
+
if (kind === "checkboxes") return controls.filter(e => e.checked).map(optionText);
|
|
193
|
+
if (kind === "consent") return el.checked ? "Yes" : "No";
|
|
194
|
+
return el.value;
|
|
195
|
+
}"""
|
|
196
|
+
|
|
197
|
+
# The step on show: the first section the page displays.
|
|
198
|
+
_STEP = """form => [...form.querySelectorAll("[data-section]")]
|
|
199
|
+
.find(s => s.getClientRects().length > 0)?.dataset.section ?? null"""
|
|
200
|
+
|
|
201
|
+
_OPTION_TEXT = """e => (e.closest("li.option, li, label")?.textContent || "")
|
|
202
|
+
.replace(/\\s+/g, " ").trim()"""
|
|
203
|
+
|
|
204
|
+
# Whether a file input's step is on show: the input itself is always hidden.
|
|
205
|
+
_FILE_SHOWN = """e => {
|
|
206
|
+
const step = e.closest("[data-section]");
|
|
207
|
+
return !step || step.getClientRects().length > 0;
|
|
208
|
+
}"""
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class Breezy:
|
|
212
|
+
name = "breezy"
|
|
213
|
+
side_effects = [
|
|
214
|
+
SideEffect("upload", "hiring_system", "an attached file is uploaded to Breezy at once"),
|
|
215
|
+
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
216
|
+
]
|
|
217
|
+
|
|
218
|
+
def __init__(self) -> None:
|
|
219
|
+
self._kinds: dict[str, str] = {} # how the page renders each field
|
|
220
|
+
|
|
221
|
+
def detect(self, url: str, page: Page | None) -> bool:
|
|
222
|
+
"""A `*.breezy.hr` position, or one on a company's domain that serves Breezy's portal."""
|
|
223
|
+
parts = urlsplit(url)
|
|
224
|
+
if not JOB.match(parts.path):
|
|
225
|
+
return False
|
|
226
|
+
if HOST.match(parts.hostname or ""):
|
|
227
|
+
return True
|
|
228
|
+
return page is not None and page.locator(PORTAL).count() > 0
|
|
229
|
+
|
|
230
|
+
def posting(self, url: str) -> str:
|
|
231
|
+
parts = urlsplit(url)
|
|
232
|
+
match = JOB.match(parts.path)
|
|
233
|
+
return f"https://{parts.hostname}/p/{match[1]}" if match else url
|
|
234
|
+
|
|
235
|
+
def read_api(self, url: str) -> list[Field] | None:
|
|
236
|
+
return None # the form's configuration is only in the page
|
|
237
|
+
|
|
238
|
+
def read_form(self, page: Page) -> list[Field]:
|
|
239
|
+
if not urlsplit(page.url).path.rstrip("/").endswith("/apply"):
|
|
240
|
+
page.goto(f"{self.posting(page.url)}/apply")
|
|
241
|
+
form = page.locator(FORM)
|
|
242
|
+
try:
|
|
243
|
+
form.locator("[name=cName]").wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
|
|
244
|
+
except Timeout:
|
|
245
|
+
raise Unsupported("posting_gone: no application form on the page") from None
|
|
246
|
+
fields, self._kinds = [], {}
|
|
247
|
+
for raw in form.evaluate(_READ):
|
|
248
|
+
if (field := _field(raw)) is not None:
|
|
249
|
+
fields.append(field)
|
|
250
|
+
self._kinds[field.id] = raw["kind"]
|
|
251
|
+
return fields + self._entries(form)
|
|
252
|
+
|
|
253
|
+
def _entries(self, form: Locator) -> list[Field]:
|
|
254
|
+
"""The first work history and education entry, when the page offers to add them."""
|
|
255
|
+
out = []
|
|
256
|
+
for prefix, entry in ENTRIES.items():
|
|
257
|
+
if not form.locator(f"a[ng-click*='{entry.add}()']").count():
|
|
258
|
+
continue
|
|
259
|
+
flag = form.locator(f"#{prefix.split('_')[0]}_required")
|
|
260
|
+
required = flag.count() > 0 and flag.first.get_attribute("value") == "required"
|
|
261
|
+
for key, label, kind in entry.fields:
|
|
262
|
+
self._kinds[f"{prefix}.{key}"] = kind
|
|
263
|
+
out.append(
|
|
264
|
+
Field(
|
|
265
|
+
f"{prefix}.{key}",
|
|
266
|
+
f"{entry.heading}: {label}",
|
|
267
|
+
KINDS[kind],
|
|
268
|
+
required and key in entry.checked,
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
return out
|
|
272
|
+
|
|
273
|
+
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
274
|
+
"""Fills the step on show, then goes on with Continue while answers are left."""
|
|
275
|
+
form = page.locator(FORM)
|
|
276
|
+
pending = {k: v for k, v in answers.items() if k in self._kinds}
|
|
277
|
+
for _ in range(MAX_STEPS):
|
|
278
|
+
for field_id in list(pending):
|
|
279
|
+
if _shown(form, field_id):
|
|
280
|
+
self._fill(form, field_id, pending.pop(field_id))
|
|
281
|
+
step = form.locator(NEXT)
|
|
282
|
+
if not pending or not step.is_visible():
|
|
283
|
+
return
|
|
284
|
+
shown = form.evaluate(_STEP)
|
|
285
|
+
browser.click(step)
|
|
286
|
+
with suppress(Timeout):
|
|
287
|
+
page.wait_for_function(
|
|
288
|
+
f"was => ({_STEP})(document.querySelector({FORM!r})) !== was",
|
|
289
|
+
arg=shown,
|
|
290
|
+
timeout=STEP_TIMEOUT_MS,
|
|
291
|
+
)
|
|
292
|
+
if form.evaluate(_STEP) == shown:
|
|
293
|
+
return # Breezy kept the step: an answer it checks is missing
|
|
294
|
+
|
|
295
|
+
def _fill(self, form: Locator, field_id: str, answer: Answer) -> None:
|
|
296
|
+
values = [answer] if isinstance(answer, str) else answer
|
|
297
|
+
entry = ENTRIES.get(field_id.split(".")[0])
|
|
298
|
+
if entry and not _controls(form, field_id).count():
|
|
299
|
+
browser.click(form.locator(f"a[ng-click*='{entry.add}()']"))
|
|
300
|
+
_controls(form, field_id).first.wait_for(timeout=STEP_TIMEOUT_MS)
|
|
301
|
+
controls = _controls(form, field_id)
|
|
302
|
+
match self._kinds[field_id]:
|
|
303
|
+
case "file":
|
|
304
|
+
controls.first.set_input_files(values[0])
|
|
305
|
+
with suppress(Timeout): # the name shows once Breezy has the upload
|
|
306
|
+
controls.first.locator(
|
|
307
|
+
"xpath=ancestor::*[contains(@class,'file-input-container')][1]"
|
|
308
|
+
).locator("a.bzyLinkColor:not(.placeholder)").first.wait_for(
|
|
309
|
+
timeout=UPLOAD_TIMEOUT_MS
|
|
310
|
+
)
|
|
311
|
+
case "select":
|
|
312
|
+
controls.first.select_option(label=values[0])
|
|
313
|
+
case "radio" | "checkboxes":
|
|
314
|
+
for value in values:
|
|
315
|
+
if (option := _option(controls, value)) is not None:
|
|
316
|
+
option.check()
|
|
317
|
+
case "consent":
|
|
318
|
+
controls.first.set_checked(values[0] == "Yes")
|
|
319
|
+
case _:
|
|
320
|
+
controls.first.fill(values[0])
|
|
321
|
+
|
|
322
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
323
|
+
form = page.locator(FORM)
|
|
324
|
+
out: dict[str, Answer] = {}
|
|
325
|
+
for field_id, kind in self._kinds.items():
|
|
326
|
+
controls = _controls(form, field_id)
|
|
327
|
+
if not controls.count():
|
|
328
|
+
continue
|
|
329
|
+
value = controls.evaluate_all(_READ_BACK, kind)
|
|
330
|
+
if value not in (None, "", []):
|
|
331
|
+
out[field_id] = value
|
|
332
|
+
return out
|
|
333
|
+
|
|
334
|
+
def submit(self, page: Page) -> str | None:
|
|
335
|
+
"""Clicks Submit Application; the evidence is Breezy's answer to the application."""
|
|
336
|
+
with page.expect_response(
|
|
337
|
+
lambda r: r.request.method == "POST" and APPLIED.search(r.url) is not None,
|
|
338
|
+
timeout=SUBMIT_TIMEOUT_MS,
|
|
339
|
+
) as applied:
|
|
340
|
+
browser.click(page.locator(FORM).locator(SUBMIT).locator("visible=true").first)
|
|
341
|
+
response = applied.value
|
|
342
|
+
if not response.ok:
|
|
343
|
+
raise RuntimeError(f"apply answered {response.status}: {response.text()[:200]}")
|
|
344
|
+
return f"{response.url} {response.status}"
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _field(raw: dict[str, Any]) -> Field | None:
|
|
348
|
+
"""The field, or None for one the page asks optionally and atsfill cannot fill."""
|
|
349
|
+
kind = raw["kind"]
|
|
350
|
+
if kind == "video":
|
|
351
|
+
if raw["required"]:
|
|
352
|
+
raise Unsupported(f"form_shape: {raw['label']!r} asks for a recorded video")
|
|
353
|
+
return None
|
|
354
|
+
label = LABELS.get(raw["id"]) or raw.get("label", "")
|
|
355
|
+
if "part" in raw:
|
|
356
|
+
label = (
|
|
357
|
+
f"{raw['question']} (reference {raw['n']} of {raw['refs']}): "
|
|
358
|
+
f"{REFERENCE_PARTS.get(raw['part'], raw['part'])}"
|
|
359
|
+
)
|
|
360
|
+
options = {"consent": YES_NO}.get(kind)
|
|
361
|
+
if kind in ("select", "radio", "checkboxes"):
|
|
362
|
+
options = raw["options"]
|
|
363
|
+
max_length = raw.get("maxLength") if KINDS[kind] is FieldType.TEXT else None
|
|
364
|
+
accept = raw.get("accept") if kind == "file" else None
|
|
365
|
+
return Field(raw["id"], label, KINDS[kind], raw["required"], options, max_length, accept)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _controls(form: Locator, field_id: str) -> Locator:
|
|
369
|
+
"""Every control of a field."""
|
|
370
|
+
prefix, _, key = field_id.partition(".")
|
|
371
|
+
if entry := ENTRIES.get(prefix):
|
|
372
|
+
return form.locator(f"[ng-model='{entry.model}.{key}']")
|
|
373
|
+
if field_id == "salaryPeriod":
|
|
374
|
+
return form.locator("select[ng-model='candidate.salary.period']")
|
|
375
|
+
if reference := REFERENCE.match(field_id):
|
|
376
|
+
question, n, part = reference.groups()
|
|
377
|
+
return (
|
|
378
|
+
form.locator(f"li.question:has([name='{question}'])")
|
|
379
|
+
.locator("li.reference")
|
|
380
|
+
.nth(int(n) - 1)
|
|
381
|
+
.locator(f"[ng-model='reference.{part}']")
|
|
382
|
+
)
|
|
383
|
+
return form.locator(f"[name='{field_id}']")
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _shown(form: Locator, field_id: str) -> bool:
|
|
387
|
+
"""Whether the field's step is on show; an entry's by the link that adds it."""
|
|
388
|
+
if entry := ENTRIES.get(field_id.split(".")[0]):
|
|
389
|
+
return form.locator(f"a[ng-click*='{entry.add}()']").is_visible()
|
|
390
|
+
control = _controls(form, field_id).first
|
|
391
|
+
if not control.count():
|
|
392
|
+
return False
|
|
393
|
+
if control.get_attribute("type") == "file":
|
|
394
|
+
return control.evaluate(_FILE_SHOWN)
|
|
395
|
+
return control.is_visible()
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _option(controls: Locator, value: str) -> Locator | None:
|
|
399
|
+
"""The radio or checkbox whose text is `value`."""
|
|
400
|
+
for control in controls.all():
|
|
401
|
+
if control.evaluate(_OPTION_TEXT) == value:
|
|
402
|
+
return control
|
|
403
|
+
return None
|
|
@@ -173,7 +173,9 @@ kind = "ats"
|
|
|
173
173
|
share = 0.0133
|
|
174
174
|
account = "none"
|
|
175
175
|
hosting = ["hosted"]
|
|
176
|
+
adapter = "bamboohr"
|
|
176
177
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
178
|
+
dry_run_verified = { date = 2026-10-21, version = "0.0.3", by = "gate", commit = "b756312" }
|
|
177
179
|
|
|
178
180
|
[taleo]
|
|
179
181
|
kind = "ats"
|
|
@@ -264,7 +266,9 @@ kind = "ats"
|
|
|
264
266
|
share = 0.0069
|
|
265
267
|
account = "none"
|
|
266
268
|
hosting = ["hosted"]
|
|
269
|
+
adapter = "breezy"
|
|
267
270
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
271
|
+
dry_run_verified = { date = 2026-10-21, version = "0.0.3", by = "gate", commit = "aac3924" }
|
|
268
272
|
|
|
269
273
|
[recruitee]
|
|
270
274
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|