atsfill 0.0.4.dev98__tar.gz → 0.0.4.dev100__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.dev98 → atsfill-0.0.4.dev100}/PKG-INFO +1 -1
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/ashby.py +4 -4
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/bamboohr.py +30 -14
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/breezy.py +3 -3
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/dayforce.py +2 -2
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/eightfold.py +3 -3
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/greenhouse.py +3 -3
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/hireology.py +3 -3
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/hiringthing.py +2 -2
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/jazzhr.py +1 -1
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/lever.py +1 -1
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/manatal.py +2 -2
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/oracle.py +13 -4
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/paycor.py +50 -3
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/paylocity.py +4 -4
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/personio.py +3 -3
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/phenom.py +76 -31
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/pinpoint.py +4 -4
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/recruitee.py +1 -1
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/rippling.py +3 -3
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/smartrecruiters.py +3 -3
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/teamtailor.py +1 -1
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/workable.py +3 -3
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/workday.py +1 -1
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/wttj.py +1 -1
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/zohorecruit.py +3 -3
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/cli.py +7 -2
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/core/adapter.py +8 -8
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/core/commands.py +11 -10
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/core/conditions.py +17 -4
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/core/matching.py +26 -10
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/core/results.py +9 -0
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/LICENSE +0 -0
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/README.md +0 -0
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/py.typed +0 -0
- {atsfill-0.0.4.dev98 → atsfill-0.0.4.dev100}/src/atsfill/registry.toml +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.dev100
|
|
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
|
|
@@ -19,7 +19,7 @@ from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
|
19
19
|
|
|
20
20
|
HOST = "jobs.ashbyhq.com"
|
|
21
21
|
GRAPHQL = f"https://{HOST}/api/non-user-graphql?op=ApiJobPosting"
|
|
22
|
-
# The fields of the page's own ApiJobPosting query that `
|
|
22
|
+
# The fields of the page's own ApiJobPosting query that `browser_read` uses.
|
|
23
23
|
JOB_POSTING = """query ApiJobPosting($org: String!, $job: String!) {
|
|
24
24
|
jobPosting(organizationHostedJobsPageName: $org, jobPostingId: $job) {
|
|
25
25
|
applicationForm { ...Form }
|
|
@@ -78,7 +78,7 @@ class Ashby:
|
|
|
78
78
|
|
|
79
79
|
def __init__(self) -> None:
|
|
80
80
|
self._forms: list[dict[str, Any]] = [] # the application form, then each survey
|
|
81
|
-
self._pending: set[Any] = set() # GraphQL requests in flight, since `
|
|
81
|
+
self._pending: set[Any] = set() # GraphQL requests in flight, since `browser_read`
|
|
82
82
|
self._last_change = 0.0
|
|
83
83
|
self._hosted: str | None = None # the jobs.ashbyhq.com posting a custom domain embeds
|
|
84
84
|
|
|
@@ -100,7 +100,7 @@ class Ashby:
|
|
|
100
100
|
def posting(self, url: str) -> str:
|
|
101
101
|
return _hosted(url) if urlsplit(url).hostname == HOST else self._hosted or url
|
|
102
102
|
|
|
103
|
-
def
|
|
103
|
+
def request_read(self, url: str) -> list[Field] | None:
|
|
104
104
|
"""The fields from the GraphQL query the job page itself sends, for a hosted URL."""
|
|
105
105
|
if urlsplit(url).hostname != HOST:
|
|
106
106
|
return None
|
|
@@ -125,7 +125,7 @@ class Ashby:
|
|
|
125
125
|
except OSError, ValueError, KeyError, TypeError, Unsupported:
|
|
126
126
|
return None
|
|
127
127
|
|
|
128
|
-
def
|
|
128
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
129
129
|
page.on("request", self._on_request)
|
|
130
130
|
page.on("requestfinished", self._on_finished)
|
|
131
131
|
page.on("requestfailed", self._on_finished)
|
|
@@ -1,8 +1,8 @@
|
|
|
1
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
|
-
answer that lists every field with its label, requirement and options. `
|
|
5
|
-
over HTTP, `
|
|
4
|
+
answer that lists every field with its label, requirement and options. `request_read` fetches it
|
|
5
|
+
over HTTP, `browser_read` through the page's own browser context, and both list the fields from
|
|
6
6
|
it. Dropdowns are Fabric menus over a hidden `select`; a file is uploaded once attached.
|
|
7
7
|
"""
|
|
8
8
|
|
|
@@ -17,7 +17,7 @@ from urllib.parse import urlsplit
|
|
|
17
17
|
from atsfill.core import browser
|
|
18
18
|
from atsfill.core.adapter import Blocked, Gap, Unsupported
|
|
19
19
|
from atsfill.core.browser import HTTP_TIMEOUT_S, Locator, Page, Timeout
|
|
20
|
-
from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
|
|
20
|
+
from atsfill.core.results import Answer, Field, FieldType, Is, OptionSet, SideEffect
|
|
21
21
|
|
|
22
22
|
HOST = re.compile(r"^[\w-]+\.bamboohr\.com$")
|
|
23
23
|
JOB = re.compile(r"^/careers/(\d+)(?:/|$)")
|
|
@@ -159,7 +159,7 @@ class BambooHR:
|
|
|
159
159
|
job = _job(url)
|
|
160
160
|
return f"https://{job[0]}/careers/{job[1]}" if job else url
|
|
161
161
|
|
|
162
|
-
def
|
|
162
|
+
def request_read(self, url: str) -> list[Field] | None:
|
|
163
163
|
if (job := _job(url)) is None:
|
|
164
164
|
return None
|
|
165
165
|
try:
|
|
@@ -169,7 +169,7 @@ class BambooHR:
|
|
|
169
169
|
except OSError, ValueError, KeyError, TypeError, Unsupported:
|
|
170
170
|
return None
|
|
171
171
|
|
|
172
|
-
def
|
|
172
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
173
173
|
host, job = _job(page.url) or ("", "")
|
|
174
174
|
form = page.locator(FORM)
|
|
175
175
|
try:
|
|
@@ -411,25 +411,41 @@ def _form(
|
|
|
411
411
|
if key == "countryId" and country:
|
|
412
412
|
by_id = {str(o["id"]): o["text"] for o in entry.get("options") or []}
|
|
413
413
|
shown = by_id.get(str(country), country if country in by_id.values() else None)
|
|
414
|
+
option_sets = None
|
|
414
415
|
if key == "state" and options:
|
|
415
|
-
|
|
416
|
-
|
|
416
|
+
countries = result["formFields"].get("countryId") or {}
|
|
417
|
+
options, option_sets = None, _states(states, countries.get("options") or [])
|
|
418
|
+
if key == "state" and not option_sets:
|
|
417
419
|
kind = "text"
|
|
418
420
|
if kind == "veteran":
|
|
419
421
|
extra = {o["id"]: o["text"] for o in entry["options"] if int(o["id"]) > 0}
|
|
420
422
|
options = list(VETERAN) + list(extra.values())
|
|
421
423
|
accept = FILE_TYPES if kind == "file" else None
|
|
422
|
-
field = Field(
|
|
424
|
+
field = Field(
|
|
425
|
+
key,
|
|
426
|
+
label,
|
|
427
|
+
TYPES[kind],
|
|
428
|
+
required,
|
|
429
|
+
options,
|
|
430
|
+
None,
|
|
431
|
+
accept,
|
|
432
|
+
default=shown,
|
|
433
|
+
option_sets=option_sets,
|
|
434
|
+
)
|
|
423
435
|
out.append((field, (kind, name, extra)))
|
|
424
436
|
return out
|
|
425
437
|
|
|
426
438
|
|
|
427
|
-
def _states(states: dict[str, Any],
|
|
428
|
-
"""The state
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
439
|
+
def _states(states: dict[str, Any], countries: list[dict[str, Any]]) -> list[OptionSet]:
|
|
440
|
+
"""The state list of each country that has one, under that country as the form lists it;
|
|
441
|
+
any other country's state is typed."""
|
|
442
|
+
by_id = {str(o["id"]): o["text"] for o in countries}
|
|
443
|
+
return [
|
|
444
|
+
OptionSet(Is("countryId", [by_id[c]]), names)
|
|
445
|
+
for c in STATE_LISTS
|
|
446
|
+
if c in by_id
|
|
447
|
+
and (names := [s["full_name"] for s in (states.get(c) or {}).get("data") or []])
|
|
448
|
+
]
|
|
433
449
|
|
|
434
450
|
|
|
435
451
|
def _custom(question: dict[str, Any]) -> list[tuple[Field, tuple[str, str | None, Any]]]:
|
|
@@ -2,8 +2,8 @@
|
|
|
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
|
|
5
|
-
under `h3` headings, the questionnaire's as `section_<section>_question_<n>`.
|
|
6
|
-
read
|
|
5
|
+
under `h3` headings, the questionnaire's as `section_<section>_question_<n>`. Only a browser
|
|
6
|
+
read lists the fields. Files upload to Breezy when attached; the rest reaches it on submit.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
import re
|
|
@@ -236,7 +236,7 @@ class Breezy:
|
|
|
236
236
|
match = JOB.match(parts.path)
|
|
237
237
|
return f"https://{parts.hostname}/p/{match[1]}" if match else url
|
|
238
238
|
|
|
239
|
-
def
|
|
239
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
240
240
|
if not urlsplit(page.url).path.rstrip("/").endswith("/apply"):
|
|
241
241
|
page.goto(f"{self.posting(page.url)}/apply")
|
|
242
242
|
form = page.locator(FORM)
|
|
@@ -197,7 +197,7 @@ class Dayforce:
|
|
|
197
197
|
found = _job(url)
|
|
198
198
|
return f"https://{HOST}/{found[1]}/{found[2]}/jobs/{found[3]}" if found else url
|
|
199
199
|
|
|
200
|
-
def
|
|
200
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
201
201
|
found = _job(page.url)
|
|
202
202
|
if found is None:
|
|
203
203
|
raise Unsupported("posting_gone: not a Dayforce job page")
|
|
@@ -481,7 +481,7 @@ class Dayforce:
|
|
|
481
481
|
option = box_id.removeprefix(f"{base}answerFollowup").removeprefix("s_")
|
|
482
482
|
followup = f"{field_id}.followup" + (f".{option}" if option else "")
|
|
483
483
|
if box.is_visible() and followup not in self._fields:
|
|
484
|
-
self._shown.append(box_id) # a comment box `
|
|
484
|
+
self._shown.append(box_id) # a comment box `browser_read` did not list
|
|
485
485
|
|
|
486
486
|
def _set_question(
|
|
487
487
|
self, page: Page, target: Locator, field: Field, question: dict[str, Any], answer: Answer
|
|
@@ -3,7 +3,7 @@ the employer's own domain), as docs/adapters/eightfold.md observes.
|
|
|
3
3
|
|
|
4
4
|
A posting's apply page (`/careers/apply?pid=<pid>`) renders its form from a questions API:
|
|
5
5
|
sections of questions by id, type, requirement and options, and the questions whose answer
|
|
6
|
-
shows each follow-up. `
|
|
6
|
+
shows each follow-up. `request_read` fetches it over HTTP, `browser_read` through the page's browser
|
|
7
7
|
context, and both list the fields from it. Each question renders as a control whose element id
|
|
8
8
|
is its section's title and its id, with every other character an underscore.
|
|
9
9
|
"""
|
|
@@ -162,7 +162,7 @@ class Eightfold:
|
|
|
162
162
|
parts, pid = urlsplit(url), _pid(url)
|
|
163
163
|
return f"https://{parts.hostname}/careers/job/{pid}" if pid else url
|
|
164
164
|
|
|
165
|
-
def
|
|
165
|
+
def request_read(self, url: str) -> list[Field] | None:
|
|
166
166
|
if (pid := _pid(url)) is None:
|
|
167
167
|
return None
|
|
168
168
|
try:
|
|
@@ -185,7 +185,7 @@ class Eightfold:
|
|
|
185
185
|
except OSError, ValueError, KeyError, TypeError, Unsupported, Blocked:
|
|
186
186
|
return None
|
|
187
187
|
|
|
188
|
-
def
|
|
188
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
189
189
|
pid = _pid(page.url)
|
|
190
190
|
if pid is None:
|
|
191
191
|
raise Unsupported(f"posting_gone: {page.url} is not a job page")
|
|
@@ -44,7 +44,7 @@ API_TYPES = {
|
|
|
44
44
|
}
|
|
45
45
|
YES_NO = ["Yes", "No"]
|
|
46
46
|
# Sections the page adds or names its own way, or shows a question of only on another answer
|
|
47
|
-
# (EEOC race after Hispanic/Latino): `
|
|
47
|
+
# (EEOC race after Hispanic/Latino): `request_read` declines a posting with any (ADR 0011).
|
|
48
48
|
UNMAPPED = ("education", "compliance", "demographic_questions")
|
|
49
49
|
|
|
50
50
|
# The fields in page order. A select's options are read by opening it, from Python.
|
|
@@ -167,7 +167,7 @@ class Greenhouse:
|
|
|
167
167
|
board, job = _board_job(url) or _board_job(self._embed or "") or ("", url)
|
|
168
168
|
return f"https://job-boards.greenhouse.io/{board}/jobs/{job}"
|
|
169
169
|
|
|
170
|
-
def
|
|
170
|
+
def request_read(self, url: str) -> list[Field] | None:
|
|
171
171
|
"""The fields from the Job Board API, unless the page holds some the API leaves out."""
|
|
172
172
|
found = _board_job(url)
|
|
173
173
|
if found is None:
|
|
@@ -177,7 +177,7 @@ class Greenhouse:
|
|
|
177
177
|
except OSError, ValueError, KeyError, TypeError:
|
|
178
178
|
return None
|
|
179
179
|
|
|
180
|
-
def
|
|
180
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
181
181
|
if self._embed:
|
|
182
182
|
page.goto(self._embed)
|
|
183
183
|
form = page.locator(FORM)
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
docs/adapters/hireology.md observes.
|
|
3
3
|
|
|
4
4
|
The job page renders its application form from a public form API, which lists every field by
|
|
5
|
-
id, type and requirement. `
|
|
5
|
+
id, type and requirement. `request_read` fetches it over HTTP, `browser_read` through the page's own
|
|
6
6
|
browser context, and both list the fields from it. Each field renders as one control whose id is
|
|
7
7
|
`<field id>-<n>`; nothing but the submit reaches Hireology.
|
|
8
8
|
"""
|
|
@@ -202,7 +202,7 @@ class Hireology:
|
|
|
202
202
|
job = _job(url)
|
|
203
203
|
return API.format(job=job) if job else url
|
|
204
204
|
|
|
205
|
-
def
|
|
205
|
+
def request_read(self, url: str) -> list[Field] | None:
|
|
206
206
|
if (job := _job(url)) is None:
|
|
207
207
|
return None
|
|
208
208
|
request = urllib.request.Request(API.format(job=job), headers={"User-Agent": "atsfill"})
|
|
@@ -212,7 +212,7 @@ class Hireology:
|
|
|
212
212
|
except OSError, ValueError, KeyError, TypeError, Unsupported:
|
|
213
213
|
return None
|
|
214
214
|
|
|
215
|
-
def
|
|
215
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
216
216
|
job = _job(page.url)
|
|
217
217
|
try:
|
|
218
218
|
page.locator(SUBMIT).wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
|
|
@@ -4,7 +4,7 @@ observes.
|
|
|
4
4
|
The job page carries the posting's configuration in a React component's props, and its Apply
|
|
5
5
|
button renders the form from them in the page: MUI inputs named by field id, radios and boxes,
|
|
6
6
|
react-select comboboxes for the country and state, Dropzone areas that upload a file as soon as
|
|
7
|
-
it is attached. `
|
|
7
|
+
it is attached. `browser_read` lists the rendered controls; there is no `request_read`, since which
|
|
8
8
|
controls render is the page's logic over the props, not the props alone.
|
|
9
9
|
"""
|
|
10
10
|
|
|
@@ -157,7 +157,7 @@ class HiringThing:
|
|
|
157
157
|
job = JOB.match(parts.path)
|
|
158
158
|
return f"https://{parts.hostname}/job/{job[1]}" if job else url
|
|
159
159
|
|
|
160
|
-
def
|
|
160
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
161
161
|
props = page.locator(PROPS).first
|
|
162
162
|
try:
|
|
163
163
|
props.wait_for(state="attached", timeout=PAGE_TIMEOUT_MS)
|
|
@@ -140,7 +140,7 @@ class JazzHR:
|
|
|
140
140
|
job = _job(url)
|
|
141
141
|
return f"https://{job[0]}/apply/{job[1]}" if job else url
|
|
142
142
|
|
|
143
|
-
def
|
|
143
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
144
144
|
form = page.locator(FORM)
|
|
145
145
|
try:
|
|
146
146
|
form.wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
|
|
@@ -133,7 +133,7 @@ class Lever:
|
|
|
133
133
|
parts, job = urlsplit(url), _job(url)
|
|
134
134
|
return f"https://{parts.hostname}/{job[0]}/{job[1]}" if job else url
|
|
135
135
|
|
|
136
|
-
def
|
|
136
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
137
137
|
if not urlsplit(page.url).path.rstrip("/").endswith("/apply"):
|
|
138
138
|
page.goto(f"{self.posting(page.url)}/apply")
|
|
139
139
|
form = page.locator(FORM)
|
|
@@ -191,7 +191,7 @@ class Manatal:
|
|
|
191
191
|
def posting(self, url: str) -> str:
|
|
192
192
|
return _posting(url)
|
|
193
193
|
|
|
194
|
-
def
|
|
194
|
+
def request_read(self, url: str) -> list[Field] | None:
|
|
195
195
|
"""The fields from the endpoint the apply page fetches, its job id from that page."""
|
|
196
196
|
client = urlsplit(url).path.strip("/").split("/")[0]
|
|
197
197
|
try:
|
|
@@ -210,7 +210,7 @@ class Manatal:
|
|
|
210
210
|
except OSError, ValueError, KeyError, TypeError:
|
|
211
211
|
return None
|
|
212
212
|
|
|
213
|
-
def
|
|
213
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
214
214
|
with page.expect_response(
|
|
215
215
|
lambda r: r.request.method == "GET" and "/application-form/" in r.url,
|
|
216
216
|
timeout=LOAD_TIMEOUT_MS,
|
|
@@ -18,7 +18,7 @@ from urllib.parse import urlsplit
|
|
|
18
18
|
from atsfill.core import browser, matching
|
|
19
19
|
from atsfill.core.adapter import Blocked, Gap, NotTaken, Unsupported, known_gap
|
|
20
20
|
from atsfill.core.browser import Locator, Page
|
|
21
|
-
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
21
|
+
from atsfill.core.results import Answer, Field, FieldType, Is, OptionSet, SideEffect
|
|
22
22
|
|
|
23
23
|
JOB = re.compile(r"/hcmUI/CandidateExperience/[\w-]+/sites/[^/]+/job/(\d+)")
|
|
24
24
|
EMAIL = "email"
|
|
@@ -266,7 +266,7 @@ class Oracle:
|
|
|
266
266
|
assert match, url
|
|
267
267
|
return f"https://{parts.hostname}/hcmUI/CandidateExperience/job/{match[1]}"
|
|
268
268
|
|
|
269
|
-
def
|
|
269
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
270
270
|
parts = urlsplit(page.url)
|
|
271
271
|
match = JOB.search(parts.path)
|
|
272
272
|
assert match, page.url # detected on a job URL
|
|
@@ -517,15 +517,24 @@ def _click(page: Page, target: Locator) -> None:
|
|
|
517
517
|
def _geography(described: list[tuple[dict, Field]]) -> list[tuple[dict, Field]]:
|
|
518
518
|
"""Leaves one field of the address's geography (state, city, postal code), the most
|
|
519
519
|
specific: its items name the levels above (`0200, Acton, ACT`), and picking one fills
|
|
520
|
-
them. It is required when any level is."""
|
|
520
|
+
them. It is required when any level is, and a dependent list of the country."""
|
|
521
521
|
levels = [(r, f) for r, f in described if r.get("geo") and f.id != COUNTRY]
|
|
522
522
|
if len(levels) < 2 or not any(f.options for _, f in levels):
|
|
523
523
|
return described
|
|
524
524
|
parts = lambda f: max(len(o.split(",")) for o in f.options or [""]) # noqa: E731
|
|
525
525
|
kept = max(levels, key=lambda rf: parts(rf[1]))
|
|
526
526
|
required = any(f.required for _, f in levels)
|
|
527
|
+
asked = replace(kept[1], required=required)
|
|
528
|
+
country = next((f.default for _, f in described if f.id == COUNTRY), None)
|
|
529
|
+
if isinstance(country, str):
|
|
530
|
+
# its items are the country's the page shows; another's cost a request
|
|
531
|
+
asked = replace(
|
|
532
|
+
asked,
|
|
533
|
+
options=None,
|
|
534
|
+
option_sets=[OptionSet(Is(COUNTRY, [country]), asked.options or [])],
|
|
535
|
+
)
|
|
527
536
|
return [
|
|
528
|
-
(r,
|
|
537
|
+
(r, asked if (r, f) == kept else f)
|
|
529
538
|
for r, f in described
|
|
530
539
|
if (r, f) == kept or (r, f) not in levels
|
|
531
540
|
]
|
|
@@ -3,17 +3,19 @@
|
|
|
3
3
|
The apply page is server-rendered. Before the form it may show pages of their own: the
|
|
4
4
|
federal contractor's self-identification (VEVRAA) and the Yes/No qualifying questions. Each
|
|
5
5
|
posts its answers to the next, which carries them as hidden inputs; the form posts everything
|
|
6
|
-
to `ApplyJob.action` on Submit. `
|
|
6
|
+
to `ApplyJob.action` on Submit. `browser_read` walks those pages to the form, `fill` walks them
|
|
7
7
|
again with the answers.
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
+
import json
|
|
10
11
|
import re
|
|
12
|
+
from dataclasses import replace
|
|
11
13
|
from typing import Any
|
|
12
14
|
from urllib.parse import parse_qs, urlencode, urlsplit
|
|
13
15
|
|
|
14
16
|
from atsfill.core.adapter import Blocked, Gap, NotTaken, Unsupported
|
|
15
17
|
from atsfill.core.browser import Page
|
|
16
|
-
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
18
|
+
from atsfill.core.results import Answer, Field, FieldType, Is, OptionSet, SideEffect
|
|
17
19
|
|
|
18
20
|
HOST = "recruitingbypaycor.com"
|
|
19
21
|
CAREER = f"https://{HOST}/career/"
|
|
@@ -120,6 +122,20 @@ _HIDDEN = """name => {
|
|
|
120
122
|
}"""
|
|
121
123
|
|
|
122
124
|
|
|
125
|
+
# Each country select with its state select, and the page scripts' text, which holds every
|
|
126
|
+
# country's state list (`var ca_country_states = [...]`).
|
|
127
|
+
_COUNTRIES = """() => ({
|
|
128
|
+
countries: [...document.querySelectorAll('#gnewtonForm select[data-state-id]')].map(c => ({
|
|
129
|
+
name: c.name,
|
|
130
|
+
state: document.getElementById(c.dataset.stateId)?.name,
|
|
131
|
+
options: [...c.options].filter(o => o.value)
|
|
132
|
+
.map(o => [o.value, o.textContent.replace(/\\s+/g, ' ').trim()]),
|
|
133
|
+
})),
|
|
134
|
+
scripts: [...document.scripts].map(s => s.textContent).join('\\n'),
|
|
135
|
+
})"""
|
|
136
|
+
STATE_LIST = re.compile(r"var (\w+)_country_states\s*=\s*(\[[^\]]*\])")
|
|
137
|
+
|
|
138
|
+
|
|
123
139
|
def _ids(url: str) -> tuple[str, str] | None:
|
|
124
140
|
"""The client and job ids of a Paycor career URL, or of an employer page opening it."""
|
|
125
141
|
query = parse_qs(urlsplit(url).query)
|
|
@@ -138,6 +154,36 @@ def _field(control: dict[str, Any]) -> Field:
|
|
|
138
154
|
return Field(fid, label, KINDS[kind], control["required"], control["options"], control["max"])
|
|
139
155
|
|
|
140
156
|
|
|
157
|
+
def _dependent(controls: list[tuple[Field, str]], page: dict[str, Any]) -> list[tuple[Field, str]]:
|
|
158
|
+
"""A state select under its country: the page's list for each country that has one, none
|
|
159
|
+
for any other, where the page disables the state and drops its `required`. Unanswered, the
|
|
160
|
+
country leaves the list the page starts with."""
|
|
161
|
+
lists = {
|
|
162
|
+
code: [s if isinstance(s, str) else s.get("value", "") for s in json.loads(names)]
|
|
163
|
+
for code, names in STATE_LIST.findall(page["scripts"])
|
|
164
|
+
}
|
|
165
|
+
under = {c["state"]: c for c in page["countries"] if c["state"]}
|
|
166
|
+
out = []
|
|
167
|
+
for field, kind in controls:
|
|
168
|
+
if (country := under.get(field.id)) and kind == "select":
|
|
169
|
+
sets = [OptionSet(Is(country["name"], negated=True), field.options or [])]
|
|
170
|
+
sets += [
|
|
171
|
+
OptionSet(Is(country["name"], [text]), lists[code.lower()])
|
|
172
|
+
for code, text in country["options"]
|
|
173
|
+
if code.lower() in lists
|
|
174
|
+
]
|
|
175
|
+
having = [text for code, text in country["options"] if code.lower() in lists]
|
|
176
|
+
field = replace(
|
|
177
|
+
field,
|
|
178
|
+
required=False,
|
|
179
|
+
required_when=Is(country["name"], having) if field.required else None,
|
|
180
|
+
options=[],
|
|
181
|
+
option_sets=sets,
|
|
182
|
+
)
|
|
183
|
+
out.append((field, kind))
|
|
184
|
+
return out
|
|
185
|
+
|
|
186
|
+
|
|
141
187
|
class Paycor:
|
|
142
188
|
name = "paycor"
|
|
143
189
|
known_gaps: dict[str, Gap] = {}
|
|
@@ -171,10 +217,11 @@ class Paycor:
|
|
|
171
217
|
job = (query.get("id") or query.get("gni") or [url])[0]
|
|
172
218
|
return f"{CAREER}JobIntroduction.action?id={job}"
|
|
173
219
|
|
|
174
|
-
def
|
|
220
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
175
221
|
self._before, self._codes = [], {}
|
|
176
222
|
self._walk(page, None)
|
|
177
223
|
controls = [(_field(c), c["kind"]) for c in page.evaluate(_CONTROLS)]
|
|
224
|
+
controls = _dependent(controls, page.evaluate(_COUNTRIES))
|
|
178
225
|
self._controls = [(f, kind) for f, kind in controls if not f.unfillable]
|
|
179
226
|
return [f for f, _ in self._before] + [f for f, _ in controls]
|
|
180
227
|
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
docs/adapters/paylocity.md observes.
|
|
3
3
|
|
|
4
4
|
The apply page carries the whole form's configuration in `window.pageData`: the sections and
|
|
5
|
-
their fields, the screener questions and the option lists. `
|
|
6
|
-
`
|
|
5
|
+
their fields, the screener questions and the option lists. `request_read` fetches it over HTTP,
|
|
6
|
+
`browser_read` takes it from the page, and both list the fields from it. The form is one page or
|
|
7
7
|
a wizard of steps that render one at a time, so `fill` keeps each step's read-back before
|
|
8
8
|
moving on; nothing reaches the employer before Submit.
|
|
9
9
|
"""
|
|
@@ -326,7 +326,7 @@ class Paylocity:
|
|
|
326
326
|
job = _job(url)
|
|
327
327
|
return f"https://{HOST}/Recruiting/Jobs/Details/{job}" if job else url
|
|
328
328
|
|
|
329
|
-
def
|
|
329
|
+
def request_read(self, url: str) -> list[Field] | None:
|
|
330
330
|
if (job := _job(url)) is None:
|
|
331
331
|
return None
|
|
332
332
|
request = urllib.request.Request(APPLY + job, headers={"User-Agent": "atsfill"})
|
|
@@ -337,7 +337,7 @@ class Paylocity:
|
|
|
337
337
|
except OSError, ValueError, KeyError, TypeError, Unsupported:
|
|
338
338
|
return None
|
|
339
339
|
|
|
340
|
-
def
|
|
340
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
341
341
|
job = _job(page.url)
|
|
342
342
|
if job and "/apply/" not in page.url.lower():
|
|
343
343
|
page.goto(APPLY + job)
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The apply page is rendered on the server, its form whole in the HTML: every field is one native
|
|
4
4
|
control named by its id, labelled by a `label[for]` or its group's label, a star marking it
|
|
5
|
-
required. `
|
|
5
|
+
required. `request_read` fetches that HTML over HTTP, `browser_read` takes the page's form, and both
|
|
6
6
|
list the fields from it with one parser. A document is uploaded to Personio once attached.
|
|
7
7
|
"""
|
|
8
8
|
|
|
@@ -72,7 +72,7 @@ class Personio:
|
|
|
72
72
|
job = _job(url)
|
|
73
73
|
return f"https://{job[0]}.jobs.personio.com/job/{job[1]}" if job else url
|
|
74
74
|
|
|
75
|
-
def
|
|
75
|
+
def request_read(self, url: str) -> list[Field] | None:
|
|
76
76
|
if _job(url) is None:
|
|
77
77
|
return None
|
|
78
78
|
request = urllib.request.Request(_apply(url), headers={"User-Agent": "atsfill"})
|
|
@@ -84,7 +84,7 @@ class Personio:
|
|
|
84
84
|
form = next((n for n in _parse(html).walk() if n.tag == "form"), None)
|
|
85
85
|
return [f for f, _ in _form(form)] if form else None
|
|
86
86
|
|
|
87
|
-
def
|
|
87
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
88
88
|
if _job(page.url) and not urlsplit(page.url).path.rstrip("/").endswith("/apply"):
|
|
89
89
|
page.goto(_apply(page.url))
|
|
90
90
|
try:
|