atsfill 0.0.4.dev47__tar.gz → 0.0.4.dev48__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.dev47 → atsfill-0.0.4.dev48}/PKG-INFO +4 -4
- {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/README.md +3 -3
- {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/adapters/ashby.py +2 -2
- {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/adapters/bamboohr.py +4 -4
- {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/adapters/breezy.py +3 -3
- {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/adapters/dayforce.py +8 -4
- {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/adapters/greenhouse.py +5 -5
- {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/adapters/hireology.py +2 -2
- {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/adapters/jazzhr.py +1 -1
- {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/adapters/lever.py +1 -1
- {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/adapters/manatal.py +2 -2
- {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/adapters/oracle.py +10 -8
- {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/adapters/paycor.py +7 -7
- {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/adapters/paylocity.py +4 -4
- {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/adapters/phenom.py +21 -21
- {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/adapters/recruitee.py +3 -1
- {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/adapters/smartrecruiters.py +5 -3
- {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/adapters/teamtailor.py +10 -6
- {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/adapters/workable.py +1 -1
- {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/adapters/wttj.py +11 -7
- {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/core/adapter.py +10 -2
- {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/core/commands.py +27 -18
- {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/core/registry.py +50 -47
- {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/core/results.py +2 -2
- {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/registry.toml +20 -20
- {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/LICENSE +0 -0
- {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev47 → atsfill-0.0.4.dev48}/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.dev48
|
|
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
|
|
@@ -139,9 +139,9 @@ flowchart LR
|
|
|
139
139
|
CLI[atsfill CLI] --> Core
|
|
140
140
|
Core -->|detect, read, fill, read back, submit| Adapter[adapter for the system]
|
|
141
141
|
Adapter --> Browser[headless Camoufox]
|
|
142
|
-
Core -->
|
|
143
|
-
|
|
144
|
-
|
|
142
|
+
Core --> SubmitGate{read-back matches,<br>submit enabled,<br>not already sent}
|
|
143
|
+
SubmitGate -->|yes| Submit[submit]
|
|
144
|
+
SubmitGate -->|no| Refused[refused]
|
|
145
145
|
```
|
|
146
146
|
|
|
147
147
|
Each command launches its own headless Camoufox, a Firefox build patched to hide automation, with a fresh context and one saved device identity.
|
|
@@ -114,9 +114,9 @@ flowchart LR
|
|
|
114
114
|
CLI[atsfill CLI] --> Core
|
|
115
115
|
Core -->|detect, read, fill, read back, submit| Adapter[adapter for the system]
|
|
116
116
|
Adapter --> Browser[headless Camoufox]
|
|
117
|
-
Core -->
|
|
118
|
-
|
|
119
|
-
|
|
117
|
+
Core --> SubmitGate{read-back matches,<br>submit enabled,<br>not already sent}
|
|
118
|
+
SubmitGate -->|yes| Submit[submit]
|
|
119
|
+
SubmitGate -->|no| Refused[refused]
|
|
120
120
|
```
|
|
121
121
|
|
|
122
122
|
Each command launches its own headless Camoufox, a Firefox build patched to hide automation, with a fresh context and one saved device identity.
|
|
@@ -166,7 +166,7 @@ class Ashby:
|
|
|
166
166
|
self._saved(page, partial(upload.set_input_files, answer))
|
|
167
167
|
case kind, _:
|
|
168
168
|
raise Unsupported(
|
|
169
|
-
f"
|
|
169
|
+
f"unsupported_rendering: cannot fill {field_id}, a {kind}, with {answer!r}"
|
|
170
170
|
)
|
|
171
171
|
self._answered = list(answers)
|
|
172
172
|
|
|
@@ -232,7 +232,7 @@ class Ashby:
|
|
|
232
232
|
f = entry["field"]
|
|
233
233
|
kind = TYPES.get(f["type"])
|
|
234
234
|
if kind is None:
|
|
235
|
-
raise Unsupported(f"
|
|
235
|
+
raise Unsupported(f"unsupported_rendering: {f['path']} is an Ashby {f['type']} field")
|
|
236
236
|
options = [v["label"] for v in f.get("selectableValues") or []] or None
|
|
237
237
|
if f["type"] == "Boolean":
|
|
238
238
|
options = ["Yes", "No"]
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""BambooHR (`<
|
|
1
|
+
"""BambooHR (`<employer>.bamboohr.com/careers/<job>`), as docs/adapters/bamboohr.md observes.
|
|
2
2
|
|
|
3
3
|
The job page renders its application form, behind Apply for This Job, from a public JSON
|
|
4
4
|
answer that lists every field with its label, requirement and options. `read_api` fetches it
|
|
@@ -198,7 +198,7 @@ class BambooHR:
|
|
|
198
198
|
else form.locator(f"[name='{name}']")
|
|
199
199
|
)
|
|
200
200
|
if not present.count():
|
|
201
|
-
raise Unsupported(f"
|
|
201
|
+
raise Unsupported(f"unsupported_flow: {field.id} is not on the page")
|
|
202
202
|
return [f for f, _ in fields]
|
|
203
203
|
|
|
204
204
|
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
@@ -388,7 +388,7 @@ def _form(
|
|
|
388
388
|
if not entry: # a demographic question the employer does not ask
|
|
389
389
|
continue
|
|
390
390
|
if key not in STANDARD:
|
|
391
|
-
raise Unsupported(f"
|
|
391
|
+
raise Unsupported(f"unsupported_rendering: {key} is a field it does not know")
|
|
392
392
|
kind, name = STANDARD[key]
|
|
393
393
|
label, required = entry["label"].strip(), entry["isRequired"]
|
|
394
394
|
options = [o["text"] for o in entry.get("options") or []] or None
|
|
@@ -418,7 +418,7 @@ def _states(states: dict[str, Any], default: str | None) -> list[str]:
|
|
|
418
418
|
def _custom(question: dict[str, Any]) -> list[tuple[Field, tuple[str, str | None, Any]]]:
|
|
419
419
|
qid, kind_api = question["id"], question["type"]
|
|
420
420
|
if kind_api not in CUSTOM:
|
|
421
|
-
raise Unsupported(f"
|
|
421
|
+
raise Unsupported(f"unsupported_rendering: question {qid} is a {kind_api}")
|
|
422
422
|
kind = CUSTOM[kind_api]
|
|
423
423
|
name = f"customQuestionAnswers.{kind_api}_{qid}"
|
|
424
424
|
label, required = question["question"].strip(), question["isRequired"]
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Breezy HR (`<
|
|
1
|
+
"""Breezy HR (`<employer>.breezy.hr/p/<position>`, or an employer's own domain), as
|
|
2
2
|
docs/adapters/breezy.md observes.
|
|
3
3
|
|
|
4
4
|
Two page engines, an AngularJS one and a React one, render the same markup: named controls
|
|
@@ -219,7 +219,7 @@ class Breezy:
|
|
|
219
219
|
self._kinds: dict[str, str] = {} # how the page renders each field
|
|
220
220
|
|
|
221
221
|
def detect(self, url: str, page: Page | None) -> bool:
|
|
222
|
-
"""A `*.breezy.hr` position, or one on
|
|
222
|
+
"""A `*.breezy.hr` position, or one on an employer's domain that serves Breezy's portal."""
|
|
223
223
|
parts = urlsplit(url)
|
|
224
224
|
if not JOB.match(parts.path):
|
|
225
225
|
return False
|
|
@@ -346,7 +346,7 @@ def _field(raw: dict[str, Any]) -> Field | None:
|
|
|
346
346
|
kind = raw["kind"]
|
|
347
347
|
if kind == "video":
|
|
348
348
|
if raw["required"]:
|
|
349
|
-
raise Unsupported(f"
|
|
349
|
+
raise Unsupported(f"unsupported_rendering: {raw['label']!r} asks for a recorded video")
|
|
350
350
|
return None
|
|
351
351
|
label = LABELS.get(raw["id"]) or raw.get("label", "")
|
|
352
352
|
if "part" in raw:
|
|
@@ -281,7 +281,7 @@ class Dayforce:
|
|
|
281
281
|
key, kind = PERSONAL_FIELDS.get(f["fieldName"], (None, None))
|
|
282
282
|
if key is None or kind is None:
|
|
283
283
|
if f["isRequired"]:
|
|
284
|
-
raise Unsupported(f"
|
|
284
|
+
raise Unsupported(f"unsupported_rendering: personal field {f['fieldName']!r}")
|
|
285
285
|
continue
|
|
286
286
|
options = {
|
|
287
287
|
"candidateSource": sources,
|
|
@@ -321,7 +321,9 @@ class Dayforce:
|
|
|
321
321
|
and ref not in CANDIDATE_INFO
|
|
322
322
|
and section.get("isRequired")
|
|
323
323
|
):
|
|
324
|
-
raise Unsupported(
|
|
324
|
+
raise Unsupported(
|
|
325
|
+
f"unsupported_rendering: {section['displayName']!r} section is required"
|
|
326
|
+
)
|
|
325
327
|
self._sections = [
|
|
326
328
|
s for s in sections if s.get("questionnaire") and s.get("xRefCode") != "SUBMIT"
|
|
327
329
|
]
|
|
@@ -549,7 +551,7 @@ def _entry_fields(
|
|
|
549
551
|
key, kind = keys.get(re.sub(r"\s", "", f["fieldName"]).upper(), (None, None))
|
|
550
552
|
if key is None or kind is None:
|
|
551
553
|
if f["isRequired"] and section["isRequired"]:
|
|
552
|
-
raise Unsupported(f"
|
|
554
|
+
raise Unsupported(f"unsupported_rendering: {prefix} field {f['fieldName']!r}")
|
|
553
555
|
continue
|
|
554
556
|
required = bool(f["isRequired"] and section["isRequired"])
|
|
555
557
|
options = NO_YES if kind == "boolean" else None
|
|
@@ -594,7 +596,9 @@ def _question_fields(section: dict[str, Any], question: dict[str, Any]) -> list[
|
|
|
594
596
|
label = _plain(question["displayName"])
|
|
595
597
|
if kind is None:
|
|
596
598
|
if question["isRequired"]:
|
|
597
|
-
raise Unsupported(
|
|
599
|
+
raise Unsupported(
|
|
600
|
+
f"unsupported_rendering: {label!r} has answer type {question['answerType']}"
|
|
601
|
+
)
|
|
598
602
|
return []
|
|
599
603
|
field_id = f"q{question['questionId']}"
|
|
600
604
|
options = None
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Greenhouse (`job-boards.greenhouse.io`, `boards.greenhouse.io`, their EU hosts, and
|
|
1
|
+
"""Greenhouse (`job-boards.greenhouse.io`, `boards.greenhouse.io`, their EU hosts, and employer
|
|
2
2
|
pages embedding them), as docs/adapters/greenhouse.md observes.
|
|
3
3
|
|
|
4
4
|
Nothing but an attached file reaches Greenhouse before submit, so the read-back is the page:
|
|
@@ -128,11 +128,11 @@ class Greenhouse:
|
|
|
128
128
|
def __init__(self) -> None:
|
|
129
129
|
self._fields: dict[str, Field] = {}
|
|
130
130
|
self._kinds: dict[str, str] = {} # how the page renders each field
|
|
131
|
-
self._embed: str | None = None # the job_app URL
|
|
131
|
+
self._embed: str | None = None # the job_app URL an employer page embeds
|
|
132
132
|
|
|
133
133
|
def detect(self, url: str, page: Page | None) -> bool:
|
|
134
|
-
"""A board URL, or
|
|
135
|
-
self._embed = None # one process detects many postings: a survey, the gate
|
|
134
|
+
"""A board URL, or an employer page whose `#grnhse_iframe` embeds a job application."""
|
|
135
|
+
self._embed = None # one process detects many postings: a survey, the adapter gate
|
|
136
136
|
if _board_job(url):
|
|
137
137
|
return True
|
|
138
138
|
if page is None:
|
|
@@ -242,7 +242,7 @@ class Greenhouse:
|
|
|
242
242
|
else:
|
|
243
243
|
field_type = FieldType.MULTI_CHOICE if kind == "multi" else FieldType.CHOICE
|
|
244
244
|
case _: # a radio input, not seen on a posting yet
|
|
245
|
-
raise Unsupported(f"
|
|
245
|
+
raise Unsupported(f"unsupported_rendering: {raw['id']} is a {kind}")
|
|
246
246
|
return Field(
|
|
247
247
|
raw["id"],
|
|
248
248
|
raw["label"],
|
|
@@ -223,7 +223,7 @@ class Hireology:
|
|
|
223
223
|
form_el = page.locator(FORM)
|
|
224
224
|
for field, _ in form:
|
|
225
225
|
if field.id not in SHOWN_BY and form_el.evaluate(_CONTROL, field.id) is None:
|
|
226
|
-
raise Unsupported(f"
|
|
226
|
+
raise Unsupported(f"unsupported_flow: {field.id} is not on the page")
|
|
227
227
|
return [f for f, _ in form]
|
|
228
228
|
|
|
229
229
|
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
@@ -304,7 +304,7 @@ def _field(entry: dict[str, Any], section: str) -> Field:
|
|
|
304
304
|
field_id, attributes = entry["id"], entry["attributes"]
|
|
305
305
|
kind = attributes["type"]
|
|
306
306
|
if kind not in TYPES:
|
|
307
|
-
raise Unsupported(f"
|
|
307
|
+
raise Unsupported(f"unsupported_rendering: {field_id} is a {kind}")
|
|
308
308
|
label = (
|
|
309
309
|
section
|
|
310
310
|
+ (
|
|
@@ -238,7 +238,7 @@ def _field(raw: dict[str, Any]) -> Field:
|
|
|
238
238
|
case "consent":
|
|
239
239
|
field_type, options = FieldType.CHOICE, YES_NO
|
|
240
240
|
case _:
|
|
241
|
-
raise Unsupported(f"
|
|
241
|
+
raise Unsupported(f"unsupported_rendering: {raw['id']} is a {kind}")
|
|
242
242
|
accept = RESUME_TYPES if kind == "file" else None
|
|
243
243
|
return Field(
|
|
244
244
|
raw["id"], raw["label"], field_type, raw["required"], options, raw.get("maxLength"), accept
|
|
@@ -205,7 +205,7 @@ def _job(url: str) -> tuple[str, str] | None:
|
|
|
205
205
|
def _field(raw: dict[str, Any]) -> Field:
|
|
206
206
|
kind = raw["kind"]
|
|
207
207
|
if kind not in KINDS:
|
|
208
|
-
raise Unsupported(f"
|
|
208
|
+
raise Unsupported(f"unsupported_rendering: {raw['id']} is a {kind}")
|
|
209
209
|
options = {"consent": YES_NO, "select": raw["options"]}.get(kind)
|
|
210
210
|
if kind in ("radio", "checkboxes"):
|
|
211
211
|
options = raw["options"]
|
|
@@ -112,7 +112,7 @@ def _field(entry: dict[str, Any], options: list[str] | None) -> Field | None:
|
|
|
112
112
|
fid, label, required = str(entry["id"]), entry["label"], bool(entry["is_required"])
|
|
113
113
|
match kind:
|
|
114
114
|
case "entries" if required:
|
|
115
|
-
raise Unsupported(f"
|
|
115
|
+
raise Unsupported(f"unsupported_rendering: {entry['field_type']} entries are required")
|
|
116
116
|
case "entries":
|
|
117
117
|
return None
|
|
118
118
|
case "file":
|
|
@@ -245,7 +245,7 @@ class Manatal:
|
|
|
245
245
|
named.evaluate(_CHECK, answer == "Yes")
|
|
246
246
|
case kind, _:
|
|
247
247
|
raise Unsupported(
|
|
248
|
-
f"
|
|
248
|
+
f"unsupported_rendering: cannot fill {field_id}, a {kind}, with {answer!r}"
|
|
249
249
|
)
|
|
250
250
|
|
|
251
251
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
@@ -16,7 +16,7 @@ from typing import Any
|
|
|
16
16
|
from urllib.parse import urlsplit
|
|
17
17
|
|
|
18
18
|
from atsfill.core import browser, matching
|
|
19
|
-
from atsfill.core.adapter import Blocked, Unsupported
|
|
19
|
+
from atsfill.core.adapter import Blocked, NotTaken, Unsupported
|
|
20
20
|
from atsfill.core.browser import Locator, Page
|
|
21
21
|
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
22
22
|
|
|
@@ -358,7 +358,7 @@ class Oracle:
|
|
|
358
358
|
except browser.Timeout:
|
|
359
359
|
if page.locator(CAPTCHA).count():
|
|
360
360
|
raise Blocked("captcha: the email step asks for an hCaptcha") from None
|
|
361
|
-
raise Unsupported("
|
|
361
|
+
raise Unsupported("unsupported_flow: the email step did not open the form") from None
|
|
362
362
|
if page.locator(CODE_PAGE).count():
|
|
363
363
|
raise Blocked(
|
|
364
364
|
f"emailed_code: {email} is known to this employer's Oracle, which emails it a"
|
|
@@ -371,7 +371,7 @@ class Oracle:
|
|
|
371
371
|
"""The field a raw description is, None for one atsfill leaves out."""
|
|
372
372
|
if raw["kind"] == "tiles":
|
|
373
373
|
if raw["required"]:
|
|
374
|
-
raise Unsupported(f"
|
|
374
|
+
raise Unsupported(f"unsupported_rendering: {raw['label']!r} needs entries added")
|
|
375
375
|
return None
|
|
376
376
|
if raw["kind"] == "email":
|
|
377
377
|
self._kinds[EMAIL] = "email"
|
|
@@ -388,7 +388,7 @@ class Oracle:
|
|
|
388
388
|
"""The innermost row holding the field."""
|
|
389
389
|
selector = page.evaluate(_ROW, field_id)
|
|
390
390
|
if selector is None:
|
|
391
|
-
raise Unsupported(f"
|
|
391
|
+
raise Unsupported(f"unsupported_flow: {field_id} left the form while it was filled")
|
|
392
392
|
return page.locator(".input-row").filter(has=page.locator(selector)).last
|
|
393
393
|
|
|
394
394
|
def _options(self, page: Page, field_id: str) -> list[str]:
|
|
@@ -454,7 +454,9 @@ class Oracle:
|
|
|
454
454
|
_choose(page, boxes.nth(1), str(int(day)), exact=True)
|
|
455
455
|
_choose(page, boxes.nth(2), year, exact=True)
|
|
456
456
|
case _:
|
|
457
|
-
raise Unsupported(
|
|
457
|
+
raise Unsupported(
|
|
458
|
+
f"unsupported_rendering: cannot fill {field_id}, a {kind}, with {answer!r}"
|
|
459
|
+
)
|
|
458
460
|
|
|
459
461
|
def _phone(self, page: Page, row: Locator, answer: str) -> None:
|
|
460
462
|
"""Picks the country code the number starts with, the longest that fits, then types
|
|
@@ -468,7 +470,7 @@ class Oracle:
|
|
|
468
470
|
)
|
|
469
471
|
prefix = next((c for c in codes if digits.startswith(c)), None)
|
|
470
472
|
if prefix is None:
|
|
471
|
-
raise
|
|
473
|
+
raise NotTaken(f"no country code for {answer!r}")
|
|
472
474
|
_choose(page, code, f"+{prefix}", code=True)
|
|
473
475
|
number = row.locator("input[type=tel]")
|
|
474
476
|
number.fill(digits[len(prefix) :])
|
|
@@ -541,7 +543,7 @@ def _go_to(page: Page, number: int) -> None:
|
|
|
541
543
|
)
|
|
542
544
|
_settle(page)
|
|
543
545
|
return
|
|
544
|
-
raise Unsupported(f"
|
|
546
|
+
raise Unsupported(f"unsupported_flow: page {number} of the form did not open")
|
|
545
547
|
|
|
546
548
|
|
|
547
549
|
def _upload(field_id: str) -> str:
|
|
@@ -611,7 +613,7 @@ def _choose(
|
|
|
611
613
|
box.press("Escape")
|
|
612
614
|
if field is not None:
|
|
613
615
|
break # a search lists nothing untyped
|
|
614
|
-
raise
|
|
616
|
+
raise NotTaken(f"no item for {answer!r} in a dropdown")
|
|
615
617
|
|
|
616
618
|
|
|
617
619
|
def _field(raw: dict[str, Any]) -> Field:
|
|
@@ -11,7 +11,7 @@ import re
|
|
|
11
11
|
from typing import Any
|
|
12
12
|
from urllib.parse import parse_qs, urlencode, urlsplit
|
|
13
13
|
|
|
14
|
-
from atsfill.core.adapter import Blocked, Unsupported
|
|
14
|
+
from atsfill.core.adapter import Blocked, NotTaken, Unsupported
|
|
15
15
|
from atsfill.core.browser import Page
|
|
16
16
|
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
17
17
|
|
|
@@ -142,7 +142,7 @@ class Paycor:
|
|
|
142
142
|
|
|
143
143
|
def detect(self, url: str, page: Page | None) -> bool:
|
|
144
144
|
"""A `recruitingbypaycor.com/career` job URL, or an employer page that embeds one."""
|
|
145
|
-
self._start = None # one process detects many postings: a survey, the gate
|
|
145
|
+
self._start = None # one process detects many postings: a survey, the adapter gate
|
|
146
146
|
parts = urlsplit(url)
|
|
147
147
|
if parts.hostname == HOST and (ids := _ids(url)):
|
|
148
148
|
self._start = self._apply(*ids)
|
|
@@ -207,7 +207,7 @@ class Paycor:
|
|
|
207
207
|
named.fill(answer)
|
|
208
208
|
named.dispatch_event("change")
|
|
209
209
|
case "select", str() if named.is_disabled():
|
|
210
|
-
raise
|
|
210
|
+
raise NotTaken(f"{field.id} has no options for that country")
|
|
211
211
|
case "select", str():
|
|
212
212
|
named.select_option(label=answer)
|
|
213
213
|
case "radio", str():
|
|
@@ -221,7 +221,7 @@ class Paycor:
|
|
|
221
221
|
self._pick(named, field, answer)
|
|
222
222
|
case _:
|
|
223
223
|
raise Unsupported(
|
|
224
|
-
f"
|
|
224
|
+
f"unsupported_rendering: cannot fill {field.id}, a {kind}, with {answer!r}"
|
|
225
225
|
)
|
|
226
226
|
|
|
227
227
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
@@ -265,7 +265,7 @@ class Paycor:
|
|
|
265
265
|
"""Opens the apply page and answers the pages before the form: the persona's picks
|
|
266
266
|
when filling, the last option and Yes when only reading, which list the fields."""
|
|
267
267
|
if self._start is None:
|
|
268
|
-
raise Unsupported("
|
|
268
|
+
raise Unsupported("unsupported_flow: no Paycor job in the page")
|
|
269
269
|
page.goto(self._start, wait_until="domcontentloaded")
|
|
270
270
|
for _ in range(STEPS):
|
|
271
271
|
page.wait_for_load_state("domcontentloaded")
|
|
@@ -286,11 +286,11 @@ class Paycor:
|
|
|
286
286
|
elif "no longer active" in (page.text_content("body") or ""):
|
|
287
287
|
raise Unsupported("posting_gone: this job is no longer active")
|
|
288
288
|
else:
|
|
289
|
-
raise Unsupported(f"
|
|
289
|
+
raise Unsupported(f"unsupported_flow: an unknown page before the form ({page.url})")
|
|
290
290
|
# The form's third-party scripts (Dropbox, Google) can hold its `load` back.
|
|
291
291
|
with page.expect_navigation(wait_until="domcontentloaded", timeout=PAGE_TIMEOUT_MS):
|
|
292
292
|
button.click(no_wait_after=True)
|
|
293
|
-
raise Unsupported("
|
|
293
|
+
raise Unsupported("unsupported_flow: no form after the pages before it")
|
|
294
294
|
|
|
295
295
|
def _eeo(self, page: Page, answers: dict[str, Answer] | None) -> None:
|
|
296
296
|
for name in page.locator(f"{EEO_FORM} input[type=radio]").evaluate_all(
|
|
@@ -405,7 +405,7 @@ def _form(data: dict[str, Any]) -> list[tuple[Field, Control]]:
|
|
|
405
405
|
count = custom.get("referencesRequiredCount") or 1
|
|
406
406
|
vocabulary = SECTIONS.get(name)
|
|
407
407
|
if vocabulary is None:
|
|
408
|
-
raise Unsupported(f"
|
|
408
|
+
raise Unsupported(f"unsupported_rendering: unknown section {name!r}")
|
|
409
409
|
included = {f["name"] for f in section["fields"] if f["isIncluded"]}
|
|
410
410
|
for n in range(count):
|
|
411
411
|
prefix = f"Reference {n + 1}: " if name == "references" else ""
|
|
@@ -418,8 +418,8 @@ def _form(data: dict[str, Any]) -> list[tuple[Field, Control]]:
|
|
|
418
418
|
if specs is None:
|
|
419
419
|
if entries and field["isRequired"] and field["name"] not in OPTIONAL_ONLY:
|
|
420
420
|
raise Unsupported(
|
|
421
|
-
|
|
422
|
-
"is not handled"
|
|
421
|
+
"unsupported_rendering: "
|
|
422
|
+
f"{_plain(field['displayName'])!r} ({field['name']}) is not handled"
|
|
423
423
|
)
|
|
424
424
|
continue
|
|
425
425
|
if SHOWN_WITH.get(field["name"], field["name"]) not in included:
|
|
@@ -437,7 +437,7 @@ def _form(data: dict[str, Any]) -> list[tuple[Field, Control]]:
|
|
|
437
437
|
elif kind == "text":
|
|
438
438
|
kind, answers, dom = "screener_text", None, f"screener-question-{i}-textarea"
|
|
439
439
|
else:
|
|
440
|
-
raise Unsupported(f"
|
|
440
|
+
raise Unsupported(f"unsupported_rendering: a {kind} screener question")
|
|
441
441
|
field_id = f"screener.{question['screenerQuestionId']}"
|
|
442
442
|
out.append((Field(field_id, _plain(question["title"]), TYPES[kind],
|
|
443
443
|
bool(question.get("isRequired")), answers),
|
|
@@ -22,7 +22,7 @@ from typing import Any
|
|
|
22
22
|
from urllib.parse import parse_qs, urlsplit
|
|
23
23
|
|
|
24
24
|
from atsfill.core import browser, matching
|
|
25
|
-
from atsfill.core.adapter import Unsupported
|
|
25
|
+
from atsfill.core.adapter import NotTaken, Unsupported
|
|
26
26
|
from atsfill.core.browser import HTTP_TIMEOUT_S, BrowserError, Covered, Locator, Page, Timeout
|
|
27
27
|
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
28
28
|
|
|
@@ -311,7 +311,7 @@ class Phenom:
|
|
|
311
311
|
try:
|
|
312
312
|
ph = _phapp_of(page.content()) # the page's scripts' own world is out of reach
|
|
313
313
|
except ValueError as e:
|
|
314
|
-
raise Unsupported(f"
|
|
314
|
+
raise Unsupported(f"unsupported_flow: {e}") from None
|
|
315
315
|
token = get(ph, "sessionParams.csrfToken") or ""
|
|
316
316
|
try:
|
|
317
317
|
fields, raws = _read(
|
|
@@ -320,7 +320,7 @@ class Phenom:
|
|
|
320
320
|
lambda body: page.evaluate(_FETCH, ["/applySubmit", body, token]),
|
|
321
321
|
)
|
|
322
322
|
except (BrowserError, ValueError, KeyError) as e:
|
|
323
|
-
raise Unsupported(f"
|
|
323
|
+
raise Unsupported(f"unsupported_flow: no configuration for the wizard ({e})") from None
|
|
324
324
|
self._raws = {raw["id"]: raw for raw in raws}
|
|
325
325
|
return fields
|
|
326
326
|
|
|
@@ -348,7 +348,7 @@ class Phenom:
|
|
|
348
348
|
if not _has_next(page):
|
|
349
349
|
return
|
|
350
350
|
_next(page, number)
|
|
351
|
-
raise Unsupported(f"
|
|
351
|
+
raise Unsupported(f"unsupported_flow: the wizard has more than {MAX_STEPS} steps")
|
|
352
352
|
|
|
353
353
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
354
354
|
return self._kept
|
|
@@ -381,7 +381,7 @@ class Phenom:
|
|
|
381
381
|
except Timeout:
|
|
382
382
|
step = parse_qs(urlsplit(page.url).query).get("stepname", ["none"])[0]
|
|
383
383
|
raise Unsupported(
|
|
384
|
-
f"
|
|
384
|
+
f"unsupported_flow: no application step on the page (step {step})"
|
|
385
385
|
) from None
|
|
386
386
|
|
|
387
387
|
def _step(self, page: Page, answers: dict[str, Answer]) -> list[dict[str, Any]]:
|
|
@@ -442,7 +442,7 @@ def _read(
|
|
|
442
442
|
reader = _Config(ph, config, texts, post)
|
|
443
443
|
raws = [raw for step in reader.steps() for raw in step]
|
|
444
444
|
if not raws:
|
|
445
|
-
raise Unsupported("
|
|
445
|
+
raise Unsupported("unsupported_flow: the wizard shows no fields")
|
|
446
446
|
resume = reader.resume()
|
|
447
447
|
return ([resume] if resume else []) + [_field(raw) for raw in raws], raws
|
|
448
448
|
|
|
@@ -816,7 +816,7 @@ class _Config:
|
|
|
816
816
|
}
|
|
817
817
|
response = get(self.call(payload, **extra), "applyGetReferences")
|
|
818
818
|
if not isinstance(response, dict):
|
|
819
|
-
raise Unsupported("
|
|
819
|
+
raise Unsupported("unsupported_flow: the job's questionnaire did not load")
|
|
820
820
|
part = response.get("data") if isinstance(get(response, "data.schema"), dict) else response
|
|
821
821
|
if not isinstance(get(part, "schema"), dict):
|
|
822
822
|
return []
|
|
@@ -1063,9 +1063,9 @@ def _apply_url(page: Page) -> str:
|
|
|
1063
1063
|
time.sleep(SETTLE_S)
|
|
1064
1064
|
target = urlsplit(href or "")
|
|
1065
1065
|
if target.hostname and target.hostname != parts.hostname:
|
|
1066
|
-
raise Unsupported(f"
|
|
1066
|
+
raise Unsupported(f"external_step: the application is on {target.hostname}")
|
|
1067
1067
|
if href and not APPLY.match(target.path):
|
|
1068
|
-
raise Unsupported(f"
|
|
1068
|
+
raise Unsupported(f"unsupported_flow: the Apply link leads to {target.path}")
|
|
1069
1069
|
if href:
|
|
1070
1070
|
return f"{parts.scheme}://{parts.netloc}{target.path}?jobSeqNo={seq}"
|
|
1071
1071
|
return f"{parts.scheme}://{parts.netloc}{prefix[1]}/apply?jobSeqNo={_seq(page.url)}"
|
|
@@ -1107,8 +1107,8 @@ def _next(page: Page, number: int) -> None:
|
|
|
1107
1107
|
return
|
|
1108
1108
|
if errors := page.locator(FORM).evaluate(_ERRORS) if page.locator(FORM).count() else []:
|
|
1109
1109
|
shown = "; ".join(f"{dom}: {message}" for dom, message in errors)
|
|
1110
|
-
raise Unsupported(f"
|
|
1111
|
-
raise Unsupported(f"
|
|
1110
|
+
raise Unsupported(f"unsupported_flow: step {number} did not pass: {shown}")
|
|
1111
|
+
raise Unsupported(f"unsupported_flow: step {number} did not pass in time")
|
|
1112
1112
|
|
|
1113
1113
|
|
|
1114
1114
|
def _click(page: Page, target: Locator) -> None:
|
|
@@ -1159,11 +1159,11 @@ def _pick_day(page: Page, dom: str, iso: str) -> None:
|
|
|
1159
1159
|
by_year = [s for s in lists if any(o.isdigit() for o in s.evaluate(OPTIONS))]
|
|
1160
1160
|
by_month = [s for s in lists if s not in by_year]
|
|
1161
1161
|
if not by_year or not by_month or not by_year[0].evaluate(_SELECT, str(year)):
|
|
1162
|
-
raise
|
|
1162
|
+
raise NotTaken(f"{dom} does not list the year {year}")
|
|
1163
1163
|
names = by_month[0].evaluate(OPTIONS)
|
|
1164
1164
|
by_month[0].evaluate(_SELECT, names[month - 1 + len(names) - 12])
|
|
1165
1165
|
elif not picker.locator(".react-datepicker__current-month").count():
|
|
1166
|
-
raise Unsupported(f"
|
|
1166
|
+
raise Unsupported(f"unsupported_rendering: {dom} has a calendar without its month")
|
|
1167
1167
|
else:
|
|
1168
1168
|
for _ in range(MAX_MONTH_STEPS):
|
|
1169
1169
|
shown = picker.locator(".react-datepicker__current-month").inner_text().split()
|
|
@@ -1173,12 +1173,12 @@ def _pick_day(page: Page, dom: str, iso: str) -> None:
|
|
|
1173
1173
|
step = "next" if at < (year, month) else "previous"
|
|
1174
1174
|
_click(page, picker.locator(f".react-datepicker__navigation--{step}"))
|
|
1175
1175
|
else:
|
|
1176
|
-
raise
|
|
1176
|
+
raise NotTaken(f"{dom} does not reach {iso}")
|
|
1177
1177
|
cell = picker.locator(
|
|
1178
1178
|
f".react-datepicker__day--{day:03d}:not(.react-datepicker__day--outside-month)"
|
|
1179
1179
|
).first
|
|
1180
1180
|
if "--disabled" in (cell.get_attribute("class") or ""):
|
|
1181
|
-
raise
|
|
1181
|
+
raise NotTaken(f"{dom} does not take {iso}")
|
|
1182
1182
|
_click(page, cell)
|
|
1183
1183
|
|
|
1184
1184
|
|
|
@@ -1190,7 +1190,7 @@ def _page_to(page: Page, picker: Locator, header: str, year: int, dom: str) -> N
|
|
|
1190
1190
|
return
|
|
1191
1191
|
step = "next" if year > shown else "previous"
|
|
1192
1192
|
_click(page, picker.locator(f".react-datepicker__navigation--{step}"))
|
|
1193
|
-
raise
|
|
1193
|
+
raise NotTaken(f"{dom} does not reach the year {year}")
|
|
1194
1194
|
|
|
1195
1195
|
|
|
1196
1196
|
def _date_tokens(form: str) -> list[str]:
|
|
@@ -1236,7 +1236,7 @@ def _upload(page: Page, path: str) -> None:
|
|
|
1236
1236
|
deadline = time.monotonic() + UPLOAD_TIMEOUT_MS / 1000
|
|
1237
1237
|
while not uploaded and not page.evaluate(_SHOWS, name):
|
|
1238
1238
|
if time.monotonic() > deadline:
|
|
1239
|
-
raise Unsupported("
|
|
1239
|
+
raise Unsupported("unsupported_rendering: the resume upload did not show the file")
|
|
1240
1240
|
time.sleep(UPLOAD_POLL_S)
|
|
1241
1241
|
deadline, quiet_since = time.monotonic() + UPLOAD_TIMEOUT_MS / 1000, time.monotonic()
|
|
1242
1242
|
while time.monotonic() < deadline and time.monotonic() - quiet_since < QUIET_S:
|
|
@@ -1270,7 +1270,7 @@ def _fill(page: Page, raw: dict[str, Any], answer: Answer) -> None:
|
|
|
1270
1270
|
break
|
|
1271
1271
|
parent = raw.get("parent")
|
|
1272
1272
|
if attempt or not parent or not (held := form.locator(f"[id='{parent}']")).count():
|
|
1273
|
-
raise
|
|
1273
|
+
raise NotTaken(f"{raw['id']} has no option {_one(answer)!r}")
|
|
1274
1274
|
held.evaluate(_SELECT, held.evaluate("el => el.selectedOptions[0]?.text.trim()"))
|
|
1275
1275
|
case "typeahead": # a list, or suggestions for what is typed
|
|
1276
1276
|
_click(page, control)
|
|
@@ -1297,7 +1297,7 @@ def _fill(page: Page, raw: dict[str, Any], answer: Answer) -> None:
|
|
|
1297
1297
|
case "radio": # its label wraps it or names it by `for`
|
|
1298
1298
|
page.evaluate(_COOKIES)
|
|
1299
1299
|
if not form.evaluate(_RADIO, [raw["id"], _one(answer)]):
|
|
1300
|
-
raise
|
|
1300
|
+
raise NotTaken(f"{raw['id']} has no option {_one(answer)!r}")
|
|
1301
1301
|
case "checkbox":
|
|
1302
1302
|
if control.is_checked() != (_one(answer) == "Yes"):
|
|
1303
1303
|
control.evaluate("el => el.click()") # the box is drawn over; React sees a click
|
|
@@ -1321,7 +1321,7 @@ def _fill(page: Page, raw: dict[str, Any], answer: Answer) -> None:
|
|
|
1321
1321
|
picker = page.locator(PICKER).last
|
|
1322
1322
|
if picker.locator("select.range-select").count():
|
|
1323
1323
|
if not picker.locator("select.range-select").evaluate(_SELECT, str(int(year))):
|
|
1324
|
-
raise
|
|
1324
|
+
raise NotTaken(f"{dom} does not list the year {year}")
|
|
1325
1325
|
else:
|
|
1326
1326
|
_page_to(page, picker, ".react-datepicker-year-header", int(year), dom)
|
|
1327
1327
|
_click(page, picker.locator(".react-datepicker__month-text").nth(int(month) - 1))
|
|
@@ -1340,7 +1340,7 @@ def _fill(page: Page, raw: dict[str, Any], answer: Answer) -> None:
|
|
|
1340
1340
|
step = "next" if year > first else "previous"
|
|
1341
1341
|
_click(page, picker.locator(f".react-datepicker__navigation--{step}"))
|
|
1342
1342
|
else:
|
|
1343
|
-
raise
|
|
1343
|
+
raise NotTaken(f"{dom} does not list the year {year}")
|
|
1344
1344
|
case "phone": # a national number goes without the code the form asks on its own
|
|
1345
1345
|
digits = re.sub(r"\D", "", _one(answer))
|
|
1346
1346
|
dial = form.evaluate(_DIAL) if raw.get("national") else None
|
|
@@ -141,7 +141,9 @@ class Recruitee:
|
|
|
141
141
|
for raw in form.evaluate(_READ):
|
|
142
142
|
if raw["kind"] == "video": # recorded in the page, or uploaded to a video service
|
|
143
143
|
if raw["required"]:
|
|
144
|
-
raise Unsupported(
|
|
144
|
+
raise Unsupported(
|
|
145
|
+
f"unsupported_rendering: {raw['label']!r} is a video question"
|
|
146
|
+
)
|
|
145
147
|
continue
|
|
146
148
|
fields.append(_field(raw))
|
|
147
149
|
self._kinds[raw["id"]] = raw["kind"]
|
|
@@ -136,7 +136,9 @@ class SmartRecruiters:
|
|
|
136
136
|
_decline_cookies(page)
|
|
137
137
|
for section in ENTRIES:
|
|
138
138
|
if page.locator(f"{section} [data-test=section-required-mark]").count():
|
|
139
|
-
raise Unsupported(
|
|
139
|
+
raise Unsupported(
|
|
140
|
+
f"unsupported_rendering: {section.removeprefix('oc-')} entries are required"
|
|
141
|
+
)
|
|
140
142
|
context = page.evaluate(_CONTEXT) or {}
|
|
141
143
|
screening = context.get("screeningConfiguration") or {}
|
|
142
144
|
self._questions = {q["id"]: q for q in screening.get("questions") or []}
|
|
@@ -208,7 +210,7 @@ class SmartRecruiters:
|
|
|
208
210
|
if kind == "currency":
|
|
209
211
|
field_type = FieldType.TEXT
|
|
210
212
|
if field_type is None or kind in OPTIONAL_ONLY:
|
|
211
|
-
raise Unsupported(f"
|
|
213
|
+
raise Unsupported(f"unsupported_rendering: {label!r} is a {kind} question")
|
|
212
214
|
options = [v["label"] for v in entry["values"]] or None
|
|
213
215
|
if kind == "checkbox":
|
|
214
216
|
options = YES_NO
|
|
@@ -254,7 +256,7 @@ class SmartRecruiters:
|
|
|
254
256
|
|
|
255
257
|
|
|
256
258
|
def _key(url: str) -> str | None:
|
|
257
|
-
"""`https://jobs.smartrecruiters.com/<
|
|
259
|
+
"""`https://jobs.smartrecruiters.com/<employer>/<job id>`, or the One Click form's URL."""
|
|
258
260
|
parts = urlsplit(url)
|
|
259
261
|
if parts.hostname not in HOSTS:
|
|
260
262
|
return None
|