atsfill 0.0.4.dev121__tar.gz → 0.0.4.dev122__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.dev121 → atsfill-0.0.4.dev122}/PKG-INFO +1 -1
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/pyproject.toml.orig +1 -1
- atsfill-0.0.4.dev122/src/atsfill/adapters/hrmdirect.py +482 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/registry.toml +1 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/LICENSE +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/README.md +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/adapters/successfactors.js +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/adapters/successfactors.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/adapters/taleo.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/adapters/ukg.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev121 → atsfill-0.0.4.dev122}/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.dev122
|
|
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
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
"""HRMDirect, now ClearCompany (`<org>.hrmdirect.com/employment/job-opening.php?req=…`, which
|
|
2
|
+
redirects to `apply.clearcompany.com/<org>?jobId=<job>`, whose Apply opens
|
|
3
|
+
`<org>.clearcompany.com/careers/jobs/<job>/apply`), as docs/adapters/hrmdirect.md observes.
|
|
4
|
+
|
|
5
|
+
Applying needs an account, one per employer (the org): its sign-up is the apply page's first
|
|
6
|
+
step, a name and an email, and sets no password. The form is a wizard of pages, each saved to
|
|
7
|
+
the application as it is left, only the current one in the DOM: the read walks the pages that
|
|
8
|
+
save with what they hold, and the read-back is each page's values, read before it is saved.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import urllib.parse
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.parse import parse_qs, urlsplit
|
|
15
|
+
|
|
16
|
+
from atsfill.core.adapter import Blocked, Gap, NotTaken, Refused, Unsupported, known_gap
|
|
17
|
+
from atsfill.core.browser import Locator, Page, Request, Timeout
|
|
18
|
+
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
19
|
+
|
|
20
|
+
OLD_HOST = re.compile(r"^([a-z0-9-]+)\.hrmdirect\.com$")
|
|
21
|
+
ORG_HOST = re.compile(r"^([a-z0-9-]+)\.clearcompany\.com$")
|
|
22
|
+
SITE_HOST = "apply.clearcompany.com"
|
|
23
|
+
UUID = r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
|
|
24
|
+
APPLY_PATH = re.compile(rf"^/careers/jobs/({UUID})(?:/apply(?:/{UUID})?)?/?$")
|
|
25
|
+
JOB_ID = re.compile(rf"^{UUID}$")
|
|
26
|
+
FORM_TIMEOUT_MS = 30_000
|
|
27
|
+
PAGE_TIMEOUT_MS = 30_000
|
|
28
|
+
UPLOAD_TIMEOUT_MS = 60_000
|
|
29
|
+
SIGN_UP_TIMEOUT_MS = 30_000
|
|
30
|
+
SUBMIT_TIMEOUT_MS = 90_000
|
|
31
|
+
SETTLE_MS = 1_500
|
|
32
|
+
MAX_PAGES = 10
|
|
33
|
+
IDENTITY = "#personal-info-form"
|
|
34
|
+
HAVE_ACCOUNT = "button.have-account-button"
|
|
35
|
+
LOGIN = "a.authenticate-button"
|
|
36
|
+
RECAPTCHA = f"{IDENTITY} iframe[title=reCAPTCHA], {IDENTITY} .g-recaptcha"
|
|
37
|
+
EMAIL_SIGN_IN = "text=Sign in with username and password instead"
|
|
38
|
+
PAGE_FORM = ".form-page-holder form.section-form"
|
|
39
|
+
ACTIVE_PAGE = ".form-progress-holder li.active"
|
|
40
|
+
NEXT = ".form-page-holder ~ * button.submit-button, button.submit-button:visible"
|
|
41
|
+
ERRORS = ".has-error:visible"
|
|
42
|
+
CLOSE_DATEPICKERS = (
|
|
43
|
+
"() => document.querySelectorAll('.datepicker.dropdown-menu')"
|
|
44
|
+
".forEach(d => { d.style.display = 'none'; })"
|
|
45
|
+
)
|
|
46
|
+
TOKEN = re.compile(r"Token\$\$:\$\$([^$]+)")
|
|
47
|
+
API_HEADERS = {"API-Realm": "CWEB", "API-AppType": "careers"}
|
|
48
|
+
|
|
49
|
+
# Each field of the current page: id, kind, label, required, options, max length, value.
|
|
50
|
+
_FIELDS = """form => [...form.querySelectorAll('.form-field')].flatMap(f => {
|
|
51
|
+
const title = f.querySelector('.field-title');
|
|
52
|
+
if (!title || f.closest('.child-field-holder:empty')) return [];
|
|
53
|
+
const label = [...title.childNodes].filter(n => !(n.classList?.contains('required-indicator')))
|
|
54
|
+
.map(n => n.textContent).join(' ').replace(/\\s+/g, ' ').trim();
|
|
55
|
+
const required = !!title.querySelector('.required-indicator');
|
|
56
|
+
const visible = !!f.offsetParent;
|
|
57
|
+
const qa = [...f.classList].find(c => c.startsWith('qa-')) || '';
|
|
58
|
+
const base = {label, required, visible, qa, options: null, max: null, value: null};
|
|
59
|
+
if (qa === 'qa-resume-upload') {
|
|
60
|
+
const kind = [...f.classList].find(c => c !== 'form-field' && c !== qa);
|
|
61
|
+
const names = [...f.querySelectorAll('.file-list tr td:first-child')]
|
|
62
|
+
.map(t => t.textContent.trim()).filter(n => n && !n.includes('Uploading'));
|
|
63
|
+
const upload = !!f.querySelector('input[type=file]');
|
|
64
|
+
return [{...base, id: 'file.' + kind, kind: upload ? 'file' : 'linkedin',
|
|
65
|
+
value: names.at(-1) || null}];
|
|
66
|
+
}
|
|
67
|
+
const boxes = [...f.querySelectorAll('input.form-radio, input.form-checkbox')];
|
|
68
|
+
if (boxes.length) {
|
|
69
|
+
const own = boxes.filter(b => b.closest('.form-field') === f);
|
|
70
|
+
const text = b => b.closest('label').textContent.replace(/\\s+/g, ' ').trim();
|
|
71
|
+
const checked = own.filter(b => b.checked).map(text);
|
|
72
|
+
return [{...base, id: own[0].dataset.field, kind: own[0].type,
|
|
73
|
+
options: own.map(text), value: checked}];
|
|
74
|
+
}
|
|
75
|
+
const control = f.querySelector('input:not([type=hidden]), select, textarea');
|
|
76
|
+
if (!control || control.closest('.form-field') !== f) return [{...base, id: '', kind: 'none'}];
|
|
77
|
+
let kind = control.tagName.toLowerCase();
|
|
78
|
+
if (kind === 'input') kind = control.classList.contains('date-input') ? 'date' : control.type;
|
|
79
|
+
const options = kind === 'select'
|
|
80
|
+
? [...control.options].filter(o => o.value).map(o => o.textContent.trim()) : null;
|
|
81
|
+
const value = kind === 'select'
|
|
82
|
+
? (control.value ? control.selectedOptions[0].textContent.trim() : null) : control.value;
|
|
83
|
+
const max = parseInt(control.dataset.max) || (control.maxLength > 0 ? control.maxLength : null);
|
|
84
|
+
return [{...base, id: control.id, kind, options, value, max}];
|
|
85
|
+
})"""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class HRMDirect:
|
|
89
|
+
name = "hrmdirect"
|
|
90
|
+
per_employer = True
|
|
91
|
+
asks_name = True
|
|
92
|
+
known_gaps = {"screening_first": Gap.FLOW, "sign_up_phone": Gap.FLOW}
|
|
93
|
+
side_effects = [
|
|
94
|
+
SideEffect(
|
|
95
|
+
"sign_up", "employer", "the account is the employer's candidate, emailed a link"
|
|
96
|
+
),
|
|
97
|
+
SideEffect("fill", "hiring_system", "each page, the resume too, is saved when left"),
|
|
98
|
+
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
def __init__(self) -> None:
|
|
102
|
+
self._jobs: dict[str, str] = {} # by org, the last posting's job
|
|
103
|
+
self._fields: dict[str, Field] = {}
|
|
104
|
+
self._kinds: dict[str, str] = {}
|
|
105
|
+
self._values: dict[str, Answer] = {}
|
|
106
|
+
self._apply = ""
|
|
107
|
+
|
|
108
|
+
def detect(self, url: str, page: Page | None) -> bool:
|
|
109
|
+
return _job(url) is not None
|
|
110
|
+
|
|
111
|
+
def posting(self, url: str) -> str:
|
|
112
|
+
"""The job's id, the same on the career site and its apply page's host; an HRMDirect
|
|
113
|
+
URL that has not redirected yet keeps its requisition."""
|
|
114
|
+
job = _job(url)
|
|
115
|
+
if job is None:
|
|
116
|
+
return url
|
|
117
|
+
org, key = job
|
|
118
|
+
if JOB_ID.match(key):
|
|
119
|
+
return f"https://{SITE_HOST}/jobs/{key.lower()}"
|
|
120
|
+
return f"https://{org}.hrmdirect.com/employment/job-opening.php?req={key}"
|
|
121
|
+
|
|
122
|
+
def account_scope(self, url: str) -> str | None:
|
|
123
|
+
"""The career site's name, or the apply host's org for an apply page."""
|
|
124
|
+
job = _job(url)
|
|
125
|
+
if job is None:
|
|
126
|
+
return None
|
|
127
|
+
if JOB_ID.match(job[1]):
|
|
128
|
+
self._jobs[job[0]] = job[1].lower()
|
|
129
|
+
return job[0]
|
|
130
|
+
|
|
131
|
+
def login_url(self, scope: str | None) -> str:
|
|
132
|
+
"""The career site's page of the last posting seen for the scope, else its job list."""
|
|
133
|
+
if scope in self._jobs:
|
|
134
|
+
return f"https://{SITE_HOST}/{scope}?jobId={self._jobs[scope]}"
|
|
135
|
+
return f"https://{SITE_HOST}/{scope}"
|
|
136
|
+
|
|
137
|
+
def signed_in(self, request: Request, scope: str | None) -> bool:
|
|
138
|
+
"""An org's `users/auth/authenticate` answers 200 for the token its cookie holds; the
|
|
139
|
+
cookie is the apply host's, which can be another org than the career site's."""
|
|
140
|
+
for cookie in request.storage_state()["cookies"]:
|
|
141
|
+
org = ORG_HOST.match(cookie["domain"].lstrip("."))
|
|
142
|
+
token = TOKEN.search(urllib.parse.unquote(cookie["value"]))
|
|
143
|
+
if cookie["name"] != "HrmBase-careers" or not org or not token:
|
|
144
|
+
continue
|
|
145
|
+
headers = API_HEADERS | {"API-ShortName": org[1], "API-Token": token[1]}
|
|
146
|
+
response = request.get(
|
|
147
|
+
f"https://{org[1]}.clearcompany.com/api/v1/users/auth/authenticate",
|
|
148
|
+
headers=headers,
|
|
149
|
+
)
|
|
150
|
+
if response.ok:
|
|
151
|
+
return True
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
def sign_in(self, page: Page, email: str, password: str) -> None:
|
|
155
|
+
"""Signs in through the header's login, whose modal takes a username and a password;
|
|
156
|
+
an account gets a password through the link its sign-up emailed."""
|
|
157
|
+
_to_apply(page)
|
|
158
|
+
if page.locator(IDENTITY).is_visible():
|
|
159
|
+
page.locator(HAVE_ACCOUNT).click()
|
|
160
|
+
page.locator(EMAIL_SIGN_IN).click()
|
|
161
|
+
page.locator("input.login-username:visible").fill(email)
|
|
162
|
+
page.locator("input.login-password:visible").fill(password)
|
|
163
|
+
page.get_by_role("button", name="Continue").locator("visible=true").click()
|
|
164
|
+
else:
|
|
165
|
+
page.locator(LOGIN).click()
|
|
166
|
+
modal = page.locator(".modal:visible").filter(has=page.locator("input[type=password]"))
|
|
167
|
+
modal.locator("input:not([type=password]):visible").first.fill(email)
|
|
168
|
+
modal.locator("input[type=password]").fill(password)
|
|
169
|
+
modal.locator("button.btn-primary").click()
|
|
170
|
+
try:
|
|
171
|
+
page.locator(f"{PAGE_FORM}, {LOGIN}:text-is('logout')").first.wait_for(
|
|
172
|
+
timeout=SIGN_UP_TIMEOUT_MS
|
|
173
|
+
)
|
|
174
|
+
except Timeout:
|
|
175
|
+
raise Refused("wrong_password") from None
|
|
176
|
+
|
|
177
|
+
def sign_up(self, page: Page, email: str, password: str, name: str | None) -> None:
|
|
178
|
+
"""Fills the apply page's first step, which creates the account signed in and sets no
|
|
179
|
+
password: the email that follows holds a link to set one, `blocked: email_link`."""
|
|
180
|
+
_to_apply(page)
|
|
181
|
+
if not page.locator(IDENTITY).is_visible():
|
|
182
|
+
raise Unsupported(
|
|
183
|
+
known_gap(self, "screening_first", "the form's first page comes before sign-up")
|
|
184
|
+
)
|
|
185
|
+
phone = page.locator(f"{IDENTITY} .form-group:has(input.mobile-number)")
|
|
186
|
+
if phone.count() and not phone.locator(".common-option-label").count():
|
|
187
|
+
raise Unsupported(
|
|
188
|
+
known_gap(self, "sign_up_phone", "the sign-up step requires a mobile phone")
|
|
189
|
+
)
|
|
190
|
+
if page.locator(RECAPTCHA).count():
|
|
191
|
+
raise Blocked("captcha: a reCAPTCHA box keeps the sign-up's Continue disabled")
|
|
192
|
+
first, _, last = (name or "").strip().partition(" ")
|
|
193
|
+
form = page.locator(IDENTITY)
|
|
194
|
+
form.locator("input.first-name").fill(first)
|
|
195
|
+
form.locator("input.last-name").fill(last or first)
|
|
196
|
+
form.locator("input.email").fill(email)
|
|
197
|
+
form.locator("input.email-confirm").fill(email)
|
|
198
|
+
for group in form.locator(".radio-group:has(input[required])").all():
|
|
199
|
+
group.locator("label:has(input[value=false])").click() # declines, the least given
|
|
200
|
+
form.locator("button.submit-button").click()
|
|
201
|
+
try:
|
|
202
|
+
moved = page.locator(f"{PAGE_FORM}, {IDENTITY} .has-error, .alert-danger:visible")
|
|
203
|
+
moved.or_(page.locator(EMAIL_SIGN_IN)).first.wait_for(timeout=SIGN_UP_TIMEOUT_MS)
|
|
204
|
+
except Timeout:
|
|
205
|
+
raise Unsupported("unhandled_rendering: the sign-up step did not move") from None
|
|
206
|
+
if page.locator(EMAIL_SIGN_IN).is_visible(): # the email has an account here
|
|
207
|
+
raise Refused("email_in_use")
|
|
208
|
+
if not page.locator(PAGE_FORM).count():
|
|
209
|
+
message = " ".join(page.locator(".alert-danger:visible, .has-error").all_inner_texts())
|
|
210
|
+
raise Refused(f"email_domain_rejected: {message.strip()[:200]}")
|
|
211
|
+
raise Blocked("email_link: set the account's password through the emailed link")
|
|
212
|
+
|
|
213
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
214
|
+
"""Walks the wizard from its first page as far as its pages save with what they hold,
|
|
215
|
+
reading each page it reaches."""
|
|
216
|
+
self._open(page)
|
|
217
|
+
self._fields = {}
|
|
218
|
+
for _ in range(MAX_PAGES):
|
|
219
|
+
for f in self._page(page):
|
|
220
|
+
self._fields.setdefault(f.id, f)
|
|
221
|
+
if not self._continue(page):
|
|
222
|
+
break
|
|
223
|
+
return list(self._fields.values())
|
|
224
|
+
|
|
225
|
+
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
226
|
+
"""Fills each page from the first and saves it, up to the last; a page showing a
|
|
227
|
+
required field the read did not list is refused, the pages before it saved."""
|
|
228
|
+
self._open(page)
|
|
229
|
+
self._values = {}
|
|
230
|
+
for _ in range(MAX_PAGES):
|
|
231
|
+
shown = self._page(page)
|
|
232
|
+
for f in shown:
|
|
233
|
+
if f.id in answers and f.id in self._kinds:
|
|
234
|
+
_fill(page, f, self._kinds[f.id], answers[f.id])
|
|
235
|
+
values = self._read(page)
|
|
236
|
+
for f in shown: # a country renders its state list again, emptying the state
|
|
237
|
+
answer = answers.get(f.id)
|
|
238
|
+
if self._kinds.get(f.id) == "select" and answer and values.get(f.id) != answer:
|
|
239
|
+
control = page.locator(PAGE_FORM).locator(f"[id='{f.id}']")
|
|
240
|
+
tag = control.evaluate("el => el.tagName") if control.count() else ""
|
|
241
|
+
_fill(page, f, "select" if tag == "SELECT" else "text", answer)
|
|
242
|
+
unread = [f.id for f in shown if f.required and f.id not in self._fields]
|
|
243
|
+
if unread:
|
|
244
|
+
raise NotTaken(f"the page shows fields the read did not list: {unread}; read again")
|
|
245
|
+
self._values |= self._read(page)
|
|
246
|
+
if _last(page) or not self._continue(page):
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
250
|
+
return self._values | self._read(page)
|
|
251
|
+
|
|
252
|
+
def submit(self, page: Page) -> str | None:
|
|
253
|
+
"""The last page's Submit; atsfill expects it to post the application. No send was
|
|
254
|
+
made."""
|
|
255
|
+
with page.expect_response(
|
|
256
|
+
lambda r: r.request.method == "POST" and "/api/" in r.url and "submit" in r.url,
|
|
257
|
+
timeout=SUBMIT_TIMEOUT_MS,
|
|
258
|
+
) as posted:
|
|
259
|
+
page.locator(NEXT).first.click()
|
|
260
|
+
response = posted.value
|
|
261
|
+
return f"{response.url} {response.status}"
|
|
262
|
+
|
|
263
|
+
def _open(self, page: Page) -> None:
|
|
264
|
+
"""The wizard's first page, loaded again; `posting_gone` for a page with no
|
|
265
|
+
application."""
|
|
266
|
+
parts = urlsplit(page.url)
|
|
267
|
+
if not self._apply or ORG_HOST.match(parts.hostname or "") is None:
|
|
268
|
+
self._apply = _apply_link(page)
|
|
269
|
+
elif APPLY_PATH.match(parts.path):
|
|
270
|
+
org, job = _job(page.url) or ("", "")
|
|
271
|
+
self._apply = _apply_url(org, job)
|
|
272
|
+
page.goto(self._apply)
|
|
273
|
+
try:
|
|
274
|
+
page.locator(f"{PAGE_FORM}, {IDENTITY}").first.wait_for(timeout=FORM_TIMEOUT_MS)
|
|
275
|
+
except Timeout:
|
|
276
|
+
raise Unsupported("posting_gone: no application form on the page") from None
|
|
277
|
+
if page.locator(IDENTITY).is_visible():
|
|
278
|
+
raise Unsupported("unhandled_rendering: the apply page asks to sign up again")
|
|
279
|
+
first = page.locator(".form-progress-holder li.page-0")
|
|
280
|
+
if first.count() and "active" not in (first.get_attribute("class") or ""):
|
|
281
|
+
first.click()
|
|
282
|
+
page.wait_for_timeout(SETTLE_MS)
|
|
283
|
+
|
|
284
|
+
def _page(self, page: Page) -> list[Field]:
|
|
285
|
+
"""The current page's fields; each kept with its kind for the fill."""
|
|
286
|
+
page.wait_for_timeout(SETTLE_MS)
|
|
287
|
+
raw: list[dict[str, Any]] = page.locator(PAGE_FORM).evaluate(_FIELDS)
|
|
288
|
+
out = []
|
|
289
|
+
for r in raw:
|
|
290
|
+
if not r["visible"] and r["kind"] != "file":
|
|
291
|
+
continue
|
|
292
|
+
field = _field(r)
|
|
293
|
+
if not field.unfillable:
|
|
294
|
+
self._kinds[field.id] = r["kind"]
|
|
295
|
+
out.append(field)
|
|
296
|
+
return out
|
|
297
|
+
|
|
298
|
+
def _read(self, page: Page) -> dict[str, Answer]:
|
|
299
|
+
out: dict[str, Answer] = {}
|
|
300
|
+
for r in page.locator(PAGE_FORM).evaluate(_FIELDS):
|
|
301
|
+
value = r["value"]
|
|
302
|
+
if r["id"] not in self._fields or value in (None, ""):
|
|
303
|
+
continue
|
|
304
|
+
if value == [] and not (r["kind"] == "checkbox" and len(r["options"]) == 1):
|
|
305
|
+
continue
|
|
306
|
+
if r["kind"] == "date" and re.match(r"^\d\d/\d\d/\d{4}$", value):
|
|
307
|
+
month, day, year = value.split("/")
|
|
308
|
+
value = f"{year}-{month}-{day}"
|
|
309
|
+
if r["kind"] == "radio":
|
|
310
|
+
value = value[0]
|
|
311
|
+
elif r["kind"] == "checkbox" and len(r["options"]) == 1:
|
|
312
|
+
value = "Yes" if value else "No"
|
|
313
|
+
out[r["id"]] = value
|
|
314
|
+
return out
|
|
315
|
+
|
|
316
|
+
def _continue(self, page: Page) -> bool:
|
|
317
|
+
"""Presses Continue; whether the next page showed, not errors."""
|
|
318
|
+
if _last(page):
|
|
319
|
+
return False
|
|
320
|
+
before = _active(page)
|
|
321
|
+
page.locator(NEXT).first.click()
|
|
322
|
+
try:
|
|
323
|
+
page.wait_for_function(
|
|
324
|
+
"([sel, before]) => { const a = document.querySelector(sel);"
|
|
325
|
+
" return (a && a.textContent !== before)"
|
|
326
|
+
" || !!document.querySelector('.has-error'); }",
|
|
327
|
+
arg=[ACTIVE_PAGE, before],
|
|
328
|
+
timeout=PAGE_TIMEOUT_MS,
|
|
329
|
+
)
|
|
330
|
+
except Timeout:
|
|
331
|
+
return False
|
|
332
|
+
return _active(page) != before
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _job(url: str) -> tuple[str, str] | None:
|
|
336
|
+
"""The org and the job: the job's id on ClearCompany, the requisition on HRMDirect."""
|
|
337
|
+
parts = urlsplit(url)
|
|
338
|
+
host = (parts.hostname or "").lower()
|
|
339
|
+
query = parse_qs(parts.query)
|
|
340
|
+
if (old := OLD_HOST.match(host)) and parts.path == "/employment/job-opening.php":
|
|
341
|
+
req = query.get("req", [""])[0]
|
|
342
|
+
return (old[1], req) if req.isdigit() else None
|
|
343
|
+
if host == SITE_HOST:
|
|
344
|
+
org = parts.path.strip("/").split("/")[0]
|
|
345
|
+
job = query.get("jobId", [""])[0]
|
|
346
|
+
return (org.lower(), job) if org and JOB_ID.match(job) else None
|
|
347
|
+
org, path = ORG_HOST.match(host), APPLY_PATH.match(parts.path)
|
|
348
|
+
return (org[1], path[1]) if org and path else None
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _apply_url(org: str, job: str) -> str:
|
|
352
|
+
return f"https://{org}.clearcompany.com/careers/jobs/{job.lower()}/apply"
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _apply_link(page: Page) -> str:
|
|
356
|
+
"""The apply page of the job page, or of an apply page already open; its host is the
|
|
357
|
+
org the application goes to, which the career site's Apply link names."""
|
|
358
|
+
if ORG_HOST.match(urlsplit(page.url).hostname or "") and "/careers/jobs/" in page.url:
|
|
359
|
+
org, job = _job(page.url) or ("", "")
|
|
360
|
+
return _apply_url(org, job)
|
|
361
|
+
link = page.locator("a[href*='/careers/jobs/'][href*='/apply']").first
|
|
362
|
+
try:
|
|
363
|
+
link.wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
|
|
364
|
+
except Timeout:
|
|
365
|
+
raise Unsupported("posting_gone: no Apply link on the page") from None
|
|
366
|
+
return link.get_attribute("href") or ""
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _to_apply(page: Page) -> None:
|
|
370
|
+
"""The apply page's first step, from the job's page or the career site's first job."""
|
|
371
|
+
if "jobId=" not in page.url and "/careers/jobs/" not in page.url:
|
|
372
|
+
jobs = page.locator("a[href*='jobId=']").first
|
|
373
|
+
try:
|
|
374
|
+
jobs.wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
|
|
375
|
+
except Timeout:
|
|
376
|
+
raise Unsupported("unhandled_rendering: no job on the career site") from None
|
|
377
|
+
page.goto(jobs.get_attribute("href") or "")
|
|
378
|
+
page.goto(_apply_link(page))
|
|
379
|
+
page.locator(f"{IDENTITY}, {PAGE_FORM}").first.wait_for(timeout=FORM_TIMEOUT_MS)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _field(r: dict[str, Any]) -> Field:
|
|
383
|
+
kinds = {
|
|
384
|
+
"text": FieldType.TEXT,
|
|
385
|
+
"email": FieldType.TEXT,
|
|
386
|
+
"tel": FieldType.TEXT,
|
|
387
|
+
"textarea": FieldType.TEXT,
|
|
388
|
+
"select": FieldType.CHOICE,
|
|
389
|
+
"radio": FieldType.CHOICE,
|
|
390
|
+
"checkbox": FieldType.MULTI_CHOICE,
|
|
391
|
+
"date": FieldType.DATE,
|
|
392
|
+
"file": FieldType.FILE,
|
|
393
|
+
}
|
|
394
|
+
label, required = r["label"] or r["id"], r["required"]
|
|
395
|
+
if r["kind"] == "linkedin":
|
|
396
|
+
reason = "out_of_scope: a LinkedIn profile is imported by signing in to LinkedIn"
|
|
397
|
+
return Field(r["id"], label, FieldType.FILE, False, unfillable=reason)
|
|
398
|
+
if r["kind"] not in kinds or not r["id"]:
|
|
399
|
+
reason = f"unhandled_rendering: {r['id'] or label} is a {r['qa'] or r['kind']}"
|
|
400
|
+
return Field(r["id"] or label, label, FieldType.TEXT, required, unfillable=reason)
|
|
401
|
+
kind = kinds[r["kind"]]
|
|
402
|
+
if kind is FieldType.MULTI_CHOICE and len(r["options"]) == 1:
|
|
403
|
+
kind = FieldType.CHOICE
|
|
404
|
+
return Field(r["id"], label, kind, required, ["Yes", "No"])
|
|
405
|
+
return Field(
|
|
406
|
+
r["id"],
|
|
407
|
+
label,
|
|
408
|
+
kind,
|
|
409
|
+
required,
|
|
410
|
+
r["options"],
|
|
411
|
+
r["max"] if kind is FieldType.TEXT else None,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _last(page: Page) -> bool:
|
|
416
|
+
"""Whether the page's primary button is Submit, not Continue."""
|
|
417
|
+
button = page.locator(NEXT).first
|
|
418
|
+
key = button.get_attribute("data-localized-key") or "" if button.count() else ""
|
|
419
|
+
return bool(button.count()) and not key.endswith("continue")
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _active(page: Page) -> str:
|
|
423
|
+
active = page.locator(ACTIVE_PAGE)
|
|
424
|
+
return active.first.inner_text() if active.count() else ""
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _fill(page: Page, field: Field, kind: str, answer: Answer) -> None:
|
|
428
|
+
values = [answer] if isinstance(answer, str) else list(answer)
|
|
429
|
+
form = page.locator(PAGE_FORM)
|
|
430
|
+
if kind in ("radio", "checkbox"):
|
|
431
|
+
boxes = form.locator(f"input[data-field='{field.id}']")
|
|
432
|
+
if kind == "checkbox" and boxes.count() == 1: # one box, answered Yes or No
|
|
433
|
+
box = boxes.first
|
|
434
|
+
if box.is_checked() != (values[0] == "Yes"):
|
|
435
|
+
box.locator("xpath=ancestor::label[1]").click()
|
|
436
|
+
return
|
|
437
|
+
for value in values:
|
|
438
|
+
box = _option(boxes, value)
|
|
439
|
+
if not box.is_checked():
|
|
440
|
+
box.locator("xpath=ancestor::label[1]").click()
|
|
441
|
+
return
|
|
442
|
+
if kind == "file":
|
|
443
|
+
_upload(form, field.id.removeprefix("file."), values[0])
|
|
444
|
+
return
|
|
445
|
+
control = form.locator(f"[id='{field.id}']")
|
|
446
|
+
if kind == "select":
|
|
447
|
+
options = field.options or []
|
|
448
|
+
if values[0] not in options:
|
|
449
|
+
raise NotTaken(f"{field.id}: no option {values[0]!r}")
|
|
450
|
+
control.select_option(label=values[0])
|
|
451
|
+
elif kind == "date":
|
|
452
|
+
year, month, day = values[0].split("-")
|
|
453
|
+
control.fill(f"{month}/{day}/{year}")
|
|
454
|
+
control.press("Tab")
|
|
455
|
+
page.evaluate(CLOSE_DATEPICKERS) # an open one covers the fields below it
|
|
456
|
+
else:
|
|
457
|
+
control.fill(values[0])
|
|
458
|
+
control.dispatch_event("change")
|
|
459
|
+
control.blur()
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _option(boxes: Locator, value: str) -> Locator:
|
|
463
|
+
for i in range(boxes.count()):
|
|
464
|
+
box = boxes.nth(i)
|
|
465
|
+
text = " ".join(box.locator("xpath=ancestor::label[1]").inner_text().split())
|
|
466
|
+
if text == value:
|
|
467
|
+
return box
|
|
468
|
+
raise NotTaken(f"no option {value!r}")
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _upload(form: Locator, kind: str, path: str) -> None:
|
|
472
|
+
"""Attaches the file and waits for its row in the field's file list."""
|
|
473
|
+
holder = form.locator(f".qa-resume-upload.{kind}")
|
|
474
|
+
holder.locator("input[type=file]").set_input_files(path)
|
|
475
|
+
name = path.rsplit("/", 1)[-1]
|
|
476
|
+
holder.page.wait_for_function(
|
|
477
|
+
"([el, name]) => [...el.querySelectorAll('.file-list tr td:first-child')]"
|
|
478
|
+
".some(t => t.textContent.trim() === name)"
|
|
479
|
+
" && !el.querySelector('.file-list').textContent.includes('Uploading')",
|
|
480
|
+
arg=[holder.element_handle(), name],
|
|
481
|
+
timeout=UPLOAD_TIMEOUT_MS,
|
|
482
|
+
)
|
|
@@ -306,6 +306,7 @@ share = 0.0059
|
|
|
306
306
|
account = "to_read"
|
|
307
307
|
hosting = ["hosted"]
|
|
308
308
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
309
|
+
unsupported = { date = 2026-10-26, version = "0.0.3", commit = "211fe03", by = "agent", reason = "unsupported_flow: screening_first: the form's first page comes before the sign-up step (4 of 14 orgs); sign_up_phone: the sign-up step requires a mobile phone, which login does not take (1 of 17); a reCAPTCHA box on the sign-up step (1 of 17); 3 orgs signed in, under the 5 dry runs the gate needs" }
|
|
309
310
|
|
|
310
311
|
[applicantpro]
|
|
311
312
|
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
|
|
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
|