atsfill 0.0.4.dev44__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.dev44 → atsfill-0.0.4.dev48}/PKG-INFO +6 -6
- {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/README.md +5 -5
- {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/pyproject.toml +2 -3
- {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/pyproject.toml.orig +2 -5
- {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/adapters/__init__.py +2 -0
- {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/adapters/ashby.py +2 -2
- {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/adapters/bamboohr.py +4 -4
- {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/adapters/breezy.py +3 -6
- {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/adapters/dayforce.py +8 -7
- {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/adapters/greenhouse.py +5 -5
- {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/adapters/hireology.py +2 -2
- {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/adapters/jazzhr.py +1 -4
- {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/adapters/lever.py +1 -4
- {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/adapters/manatal.py +2 -2
- {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/adapters/oracle.py +10 -11
- {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/adapters/paycor.py +7 -10
- {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/adapters/paylocity.py +4 -4
- atsfill-0.0.4.dev48/src/atsfill/adapters/phenom.py +1353 -0
- {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/adapters/recruitee.py +3 -4
- {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/adapters/smartrecruiters.py +5 -6
- {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/adapters/teamtailor.py +10 -9
- {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/adapters/workable.py +1 -1
- {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/adapters/wttj.py +11 -10
- {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/core/adapter.py +19 -6
- {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/core/browser.py +5 -3
- {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/core/commands.py +29 -18
- {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/core/registry.py +69 -48
- {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/core/results.py +2 -2
- {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/registry.toml +24 -19
- {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/LICENSE +0 -0
- {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev44 → atsfill-0.0.4.dev48}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev44 → 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
|
|
@@ -54,7 +54,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
54
54
|
## Supported hiring systems
|
|
55
55
|
|
|
56
56
|
<!-- badges -->
|
|
57
|
-
                
|
|
57
|
+
                 
|
|
58
58
|
<!-- /badges -->
|
|
59
59
|
|
|
60
60
|
| System | Account needed | `read` without a browser |
|
|
@@ -89,7 +89,7 @@ uvx atsfill install-browser
|
|
|
89
89
|
|
|
90
90
|
This downloads the Camoufox browser build atsfill is pinned to: 1.3 GB, about 2.5 GB once unpacked.
|
|
91
91
|
|
|
92
|
-
Every push to `main` that passes CI is also published as a dev release
|
|
92
|
+
Every push to `main` that passes CI is also published as a dev release; `uvx --prerelease allow atsfill` runs the newest.
|
|
93
93
|
|
|
94
94
|
## Quickstart
|
|
95
95
|
|
|
@@ -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.
|
|
@@ -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 |
|
|
@@ -64,7 +64,7 @@ uvx atsfill install-browser
|
|
|
64
64
|
|
|
65
65
|
This downloads the Camoufox browser build atsfill is pinned to: 1.3 GB, about 2.5 GB once unpacked.
|
|
66
66
|
|
|
67
|
-
Every push to `main` that passes CI is also published as a dev release
|
|
67
|
+
Every push to `main` that passes CI is also published as a dev release; `uvx --prerelease allow atsfill` runs the newest.
|
|
68
68
|
|
|
69
69
|
## Quickstart
|
|
70
70
|
|
|
@@ -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.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "atsfill"
|
|
3
|
-
version = "0.0.4.
|
|
3
|
+
version = "0.0.4.dev48"
|
|
4
4
|
description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -70,8 +70,7 @@ select = [
|
|
|
70
70
|
|
|
71
71
|
[tool.pytest.ini_options]
|
|
72
72
|
testpaths = ["tests"]
|
|
73
|
-
|
|
74
|
-
addopts = "-m 'not live' -n auto --dist loadscope"
|
|
73
|
+
addopts = "-n auto --dist loadscope"
|
|
75
74
|
|
|
76
75
|
[[tool.uv.index]]
|
|
77
76
|
name = "testpypi"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "atsfill"
|
|
3
|
-
version = "0.0.4.
|
|
3
|
+
version = "0.0.4.dev48"
|
|
4
4
|
description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -53,10 +53,7 @@ select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
|
53
53
|
|
|
54
54
|
[tool.pytest.ini_options]
|
|
55
55
|
testpaths = ["tests"]
|
|
56
|
-
|
|
57
|
-
"live: drives a real hiring system page with data from .env and .tmp/; opt-in with -m live",
|
|
58
|
-
]
|
|
59
|
-
addopts = "-m 'not live' -n auto --dist loadscope"
|
|
56
|
+
addopts = "-n auto --dist loadscope"
|
|
60
57
|
|
|
61
58
|
[[tool.uv.index]]
|
|
62
59
|
name = "testpypi"
|
|
@@ -10,6 +10,7 @@ from atsfill.adapters.manatal import Manatal
|
|
|
10
10
|
from atsfill.adapters.oracle import Oracle
|
|
11
11
|
from atsfill.adapters.paycor import Paycor
|
|
12
12
|
from atsfill.adapters.paylocity import Paylocity
|
|
13
|
+
from atsfill.adapters.phenom import Phenom
|
|
13
14
|
from atsfill.adapters.recruitee import Recruitee
|
|
14
15
|
from atsfill.adapters.smartrecruiters import SmartRecruiters
|
|
15
16
|
from atsfill.adapters.teamtailor import Teamtailor
|
|
@@ -35,4 +36,5 @@ ADAPTERS: list[Adapter] = [
|
|
|
35
36
|
Recruitee(),
|
|
36
37
|
Breezy(),
|
|
37
38
|
BambooHR(),
|
|
39
|
+
Phenom(), # last: it claims a URL by its path alone
|
|
38
40
|
]
|
|
@@ -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
|
|
@@ -232,9 +232,6 @@ class Breezy:
|
|
|
232
232
|
match = JOB.match(parts.path)
|
|
233
233
|
return f"https://{parts.hostname}/p/{match[1]}" if match else url
|
|
234
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
235
|
def read_form(self, page: Page) -> list[Field]:
|
|
239
236
|
if not urlsplit(page.url).path.rstrip("/").endswith("/apply"):
|
|
240
237
|
page.goto(f"{self.posting(page.url)}/apply")
|
|
@@ -349,7 +346,7 @@ def _field(raw: dict[str, Any]) -> Field | None:
|
|
|
349
346
|
kind = raw["kind"]
|
|
350
347
|
if kind == "video":
|
|
351
348
|
if raw["required"]:
|
|
352
|
-
raise Unsupported(f"
|
|
349
|
+
raise Unsupported(f"unsupported_rendering: {raw['label']!r} asks for a recorded video")
|
|
353
350
|
return None
|
|
354
351
|
label = LABELS.get(raw["id"]) or raw.get("label", "")
|
|
355
352
|
if "part" in raw:
|
|
@@ -192,9 +192,6 @@ class Dayforce:
|
|
|
192
192
|
found = _job(url)
|
|
193
193
|
return f"https://{HOST}/{found[1]}/{found[2]}/jobs/{found[3]}" if found else url
|
|
194
194
|
|
|
195
|
-
def read_api(self, url: str) -> list[Field] | None:
|
|
196
|
-
return None # the template leaves out the options of the country and contact dropdowns
|
|
197
|
-
|
|
198
195
|
def read_form(self, page: Page) -> list[Field]:
|
|
199
196
|
found = _job(page.url)
|
|
200
197
|
if found is None:
|
|
@@ -284,7 +281,7 @@ class Dayforce:
|
|
|
284
281
|
key, kind = PERSONAL_FIELDS.get(f["fieldName"], (None, None))
|
|
285
282
|
if key is None or kind is None:
|
|
286
283
|
if f["isRequired"]:
|
|
287
|
-
raise Unsupported(f"
|
|
284
|
+
raise Unsupported(f"unsupported_rendering: personal field {f['fieldName']!r}")
|
|
288
285
|
continue
|
|
289
286
|
options = {
|
|
290
287
|
"candidateSource": sources,
|
|
@@ -324,7 +321,9 @@ class Dayforce:
|
|
|
324
321
|
and ref not in CANDIDATE_INFO
|
|
325
322
|
and section.get("isRequired")
|
|
326
323
|
):
|
|
327
|
-
raise Unsupported(
|
|
324
|
+
raise Unsupported(
|
|
325
|
+
f"unsupported_rendering: {section['displayName']!r} section is required"
|
|
326
|
+
)
|
|
328
327
|
self._sections = [
|
|
329
328
|
s for s in sections if s.get("questionnaire") and s.get("xRefCode") != "SUBMIT"
|
|
330
329
|
]
|
|
@@ -552,7 +551,7 @@ def _entry_fields(
|
|
|
552
551
|
key, kind = keys.get(re.sub(r"\s", "", f["fieldName"]).upper(), (None, None))
|
|
553
552
|
if key is None or kind is None:
|
|
554
553
|
if f["isRequired"] and section["isRequired"]:
|
|
555
|
-
raise Unsupported(f"
|
|
554
|
+
raise Unsupported(f"unsupported_rendering: {prefix} field {f['fieldName']!r}")
|
|
556
555
|
continue
|
|
557
556
|
required = bool(f["isRequired"] and section["isRequired"])
|
|
558
557
|
options = NO_YES if kind == "boolean" else None
|
|
@@ -597,7 +596,9 @@ def _question_fields(section: dict[str, Any], question: dict[str, Any]) -> list[
|
|
|
597
596
|
label = _plain(question["displayName"])
|
|
598
597
|
if kind is None:
|
|
599
598
|
if question["isRequired"]:
|
|
600
|
-
raise Unsupported(
|
|
599
|
+
raise Unsupported(
|
|
600
|
+
f"unsupported_rendering: {label!r} has answer type {question['answerType']}"
|
|
601
|
+
)
|
|
601
602
|
return []
|
|
602
603
|
field_id = f"q{question['questionId']}"
|
|
603
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
|
+ (
|
|
@@ -134,9 +134,6 @@ class JazzHR:
|
|
|
134
134
|
job = _job(url)
|
|
135
135
|
return f"https://{job[0]}/apply/{job[1]}" if job else url
|
|
136
136
|
|
|
137
|
-
def read_api(self, url: str) -> list[Field] | None:
|
|
138
|
-
return None
|
|
139
|
-
|
|
140
137
|
def read_form(self, page: Page) -> list[Field]:
|
|
141
138
|
form = page.locator(FORM)
|
|
142
139
|
try:
|
|
@@ -241,7 +238,7 @@ def _field(raw: dict[str, Any]) -> Field:
|
|
|
241
238
|
case "consent":
|
|
242
239
|
field_type, options = FieldType.CHOICE, YES_NO
|
|
243
240
|
case _:
|
|
244
|
-
raise Unsupported(f"
|
|
241
|
+
raise Unsupported(f"unsupported_rendering: {raw['id']} is a {kind}")
|
|
245
242
|
accept = RESUME_TYPES if kind == "file" else None
|
|
246
243
|
return Field(
|
|
247
244
|
raw["id"], raw["label"], field_type, raw["required"], options, raw.get("maxLength"), accept
|
|
@@ -132,9 +132,6 @@ class Lever:
|
|
|
132
132
|
parts, job = urlsplit(url), _job(url)
|
|
133
133
|
return f"https://{parts.hostname}/{job[0]}/{job[1]}" if job else url
|
|
134
134
|
|
|
135
|
-
def read_api(self, url: str) -> list[Field] | None:
|
|
136
|
-
return None # Lever's postings API leaves the application's questions out
|
|
137
|
-
|
|
138
135
|
def read_form(self, page: Page) -> list[Field]:
|
|
139
136
|
if not urlsplit(page.url).path.rstrip("/").endswith("/apply"):
|
|
140
137
|
page.goto(f"{self.posting(page.url)}/apply")
|
|
@@ -208,7 +205,7 @@ def _job(url: str) -> tuple[str, str] | None:
|
|
|
208
205
|
def _field(raw: dict[str, Any]) -> Field:
|
|
209
206
|
kind = raw["kind"]
|
|
210
207
|
if kind not in KINDS:
|
|
211
|
-
raise Unsupported(f"
|
|
208
|
+
raise Unsupported(f"unsupported_rendering: {raw['id']} is a {kind}")
|
|
212
209
|
options = {"consent": YES_NO, "select": raw["options"]}.get(kind)
|
|
213
210
|
if kind in ("radio", "checkboxes"):
|
|
214
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
|
|
|
@@ -259,9 +259,6 @@ class Oracle:
|
|
|
259
259
|
assert match, url
|
|
260
260
|
return f"https://{parts.hostname}/hcmUI/CandidateExperience/job/{match[1]}"
|
|
261
261
|
|
|
262
|
-
def read_api(self, url: str) -> list[Field] | None:
|
|
263
|
-
return None # the form's metadata is served only past the email step
|
|
264
|
-
|
|
265
262
|
def read_form(self, page: Page) -> list[Field]:
|
|
266
263
|
parts = urlsplit(page.url)
|
|
267
264
|
match = JOB.search(parts.path)
|
|
@@ -361,7 +358,7 @@ class Oracle:
|
|
|
361
358
|
except browser.Timeout:
|
|
362
359
|
if page.locator(CAPTCHA).count():
|
|
363
360
|
raise Blocked("captcha: the email step asks for an hCaptcha") from None
|
|
364
|
-
raise Unsupported("
|
|
361
|
+
raise Unsupported("unsupported_flow: the email step did not open the form") from None
|
|
365
362
|
if page.locator(CODE_PAGE).count():
|
|
366
363
|
raise Blocked(
|
|
367
364
|
f"emailed_code: {email} is known to this employer's Oracle, which emails it a"
|
|
@@ -374,7 +371,7 @@ class Oracle:
|
|
|
374
371
|
"""The field a raw description is, None for one atsfill leaves out."""
|
|
375
372
|
if raw["kind"] == "tiles":
|
|
376
373
|
if raw["required"]:
|
|
377
|
-
raise Unsupported(f"
|
|
374
|
+
raise Unsupported(f"unsupported_rendering: {raw['label']!r} needs entries added")
|
|
378
375
|
return None
|
|
379
376
|
if raw["kind"] == "email":
|
|
380
377
|
self._kinds[EMAIL] = "email"
|
|
@@ -391,7 +388,7 @@ class Oracle:
|
|
|
391
388
|
"""The innermost row holding the field."""
|
|
392
389
|
selector = page.evaluate(_ROW, field_id)
|
|
393
390
|
if selector is None:
|
|
394
|
-
raise Unsupported(f"
|
|
391
|
+
raise Unsupported(f"unsupported_flow: {field_id} left the form while it was filled")
|
|
395
392
|
return page.locator(".input-row").filter(has=page.locator(selector)).last
|
|
396
393
|
|
|
397
394
|
def _options(self, page: Page, field_id: str) -> list[str]:
|
|
@@ -457,7 +454,9 @@ class Oracle:
|
|
|
457
454
|
_choose(page, boxes.nth(1), str(int(day)), exact=True)
|
|
458
455
|
_choose(page, boxes.nth(2), year, exact=True)
|
|
459
456
|
case _:
|
|
460
|
-
raise Unsupported(
|
|
457
|
+
raise Unsupported(
|
|
458
|
+
f"unsupported_rendering: cannot fill {field_id}, a {kind}, with {answer!r}"
|
|
459
|
+
)
|
|
461
460
|
|
|
462
461
|
def _phone(self, page: Page, row: Locator, answer: str) -> None:
|
|
463
462
|
"""Picks the country code the number starts with, the longest that fits, then types
|
|
@@ -471,7 +470,7 @@ class Oracle:
|
|
|
471
470
|
)
|
|
472
471
|
prefix = next((c for c in codes if digits.startswith(c)), None)
|
|
473
472
|
if prefix is None:
|
|
474
|
-
raise
|
|
473
|
+
raise NotTaken(f"no country code for {answer!r}")
|
|
475
474
|
_choose(page, code, f"+{prefix}", code=True)
|
|
476
475
|
number = row.locator("input[type=tel]")
|
|
477
476
|
number.fill(digits[len(prefix) :])
|
|
@@ -544,7 +543,7 @@ def _go_to(page: Page, number: int) -> None:
|
|
|
544
543
|
)
|
|
545
544
|
_settle(page)
|
|
546
545
|
return
|
|
547
|
-
raise Unsupported(f"
|
|
546
|
+
raise Unsupported(f"unsupported_flow: page {number} of the form did not open")
|
|
548
547
|
|
|
549
548
|
|
|
550
549
|
def _upload(field_id: str) -> str:
|
|
@@ -614,7 +613,7 @@ def _choose(
|
|
|
614
613
|
box.press("Escape")
|
|
615
614
|
if field is not None:
|
|
616
615
|
break # a search lists nothing untyped
|
|
617
|
-
raise
|
|
616
|
+
raise NotTaken(f"no item for {answer!r} in a dropdown")
|
|
618
617
|
|
|
619
618
|
|
|
620
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)
|
|
@@ -160,9 +160,6 @@ class Paycor:
|
|
|
160
160
|
job = (query.get("id") or query.get("gni") or [url])[0]
|
|
161
161
|
return f"{CAREER}JobIntroduction.action?id={job}"
|
|
162
162
|
|
|
163
|
-
def read_api(self, url: str) -> list[Field] | None:
|
|
164
|
-
return None # the form is behind the pages before it, posted in a session
|
|
165
|
-
|
|
166
163
|
def read_form(self, page: Page) -> list[Field]:
|
|
167
164
|
self._before, self._codes = [], {}
|
|
168
165
|
self._walk(page, None)
|
|
@@ -210,7 +207,7 @@ class Paycor:
|
|
|
210
207
|
named.fill(answer)
|
|
211
208
|
named.dispatch_event("change")
|
|
212
209
|
case "select", str() if named.is_disabled():
|
|
213
|
-
raise
|
|
210
|
+
raise NotTaken(f"{field.id} has no options for that country")
|
|
214
211
|
case "select", str():
|
|
215
212
|
named.select_option(label=answer)
|
|
216
213
|
case "radio", str():
|
|
@@ -224,7 +221,7 @@ class Paycor:
|
|
|
224
221
|
self._pick(named, field, answer)
|
|
225
222
|
case _:
|
|
226
223
|
raise Unsupported(
|
|
227
|
-
f"
|
|
224
|
+
f"unsupported_rendering: cannot fill {field.id}, a {kind}, with {answer!r}"
|
|
228
225
|
)
|
|
229
226
|
|
|
230
227
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
@@ -268,7 +265,7 @@ class Paycor:
|
|
|
268
265
|
"""Opens the apply page and answers the pages before the form: the persona's picks
|
|
269
266
|
when filling, the last option and Yes when only reading, which list the fields."""
|
|
270
267
|
if self._start is None:
|
|
271
|
-
raise Unsupported("
|
|
268
|
+
raise Unsupported("unsupported_flow: no Paycor job in the page")
|
|
272
269
|
page.goto(self._start, wait_until="domcontentloaded")
|
|
273
270
|
for _ in range(STEPS):
|
|
274
271
|
page.wait_for_load_state("domcontentloaded")
|
|
@@ -289,11 +286,11 @@ class Paycor:
|
|
|
289
286
|
elif "no longer active" in (page.text_content("body") or ""):
|
|
290
287
|
raise Unsupported("posting_gone: this job is no longer active")
|
|
291
288
|
else:
|
|
292
|
-
raise Unsupported(f"
|
|
289
|
+
raise Unsupported(f"unsupported_flow: an unknown page before the form ({page.url})")
|
|
293
290
|
# The form's third-party scripts (Dropbox, Google) can hold its `load` back.
|
|
294
291
|
with page.expect_navigation(wait_until="domcontentloaded", timeout=PAGE_TIMEOUT_MS):
|
|
295
292
|
button.click(no_wait_after=True)
|
|
296
|
-
raise Unsupported("
|
|
293
|
+
raise Unsupported("unsupported_flow: no form after the pages before it")
|
|
297
294
|
|
|
298
295
|
def _eeo(self, page: Page, answers: dict[str, Answer] | None) -> None:
|
|
299
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),
|