atsfill 0.0.7.dev212__tar.gz → 0.0.8.dev219__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.7.dev212 → atsfill-0.0.8.dev219}/PKG-INFO +1 -1
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/pyproject.toml +1 -1
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/pyproject.toml.orig +1 -1
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/breezy.py +5 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/greenhouse.py +42 -3
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/hiringthing.py +20 -2
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/jazzhr.py +3 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/jobaffinity.py +19 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/jobstreet.py +14 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/join.py +8 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/lever.py +19 -6
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/manatal.py +20 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/oracle.py +73 -9
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/paycor.py +4 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/personio.py +4 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/recruitee.py +4 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/rippling.py +33 -4
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/successfactors.py +3 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/ukg.py +28 -5
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/welcomekit.py +5 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/wellfound.py +16 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/werecruit.py +3 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/workable.py +3 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/workatastartup.py +18 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/workday.py +9 -2
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/wttj.py +7 -2
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/LICENSE +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/README.md +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/adp.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/adpmyjobs.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/gupy.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/hrmdirect.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/isolvedhire.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/recruiterflow.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/successfactors.js +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/taleo.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/core/countries.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/core/otp.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/core/snapshot.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/core/telemetry.py +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/py.typed +0 -0
- {atsfill-0.0.7.dev212 → atsfill-0.0.8.dev219}/src/atsfill/registry.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: atsfill
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.8.dev219
|
|
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
|
|
@@ -328,6 +328,11 @@ class Breezy:
|
|
|
328
328
|
case _:
|
|
329
329
|
controls.first.fill(values[0])
|
|
330
330
|
|
|
331
|
+
def shown_fields(self, page: Page) -> list[str]:
|
|
332
|
+
"""The controls `browser_read` names; a work history or education entry's have no name.
|
|
333
|
+
Every step stays in the page, the others hidden."""
|
|
334
|
+
return [raw["id"] for raw in page.locator(FORM).evaluate(_READ)]
|
|
335
|
+
|
|
331
336
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
332
337
|
form = page.locator(FORM)
|
|
333
338
|
out: dict[str, Answer] = {}
|
|
@@ -11,13 +11,14 @@ import re
|
|
|
11
11
|
import time
|
|
12
12
|
import urllib.request
|
|
13
13
|
from contextlib import suppress
|
|
14
|
+
from dataclasses import replace
|
|
14
15
|
from typing import Any
|
|
15
16
|
from urllib.parse import parse_qs, urlencode, urlsplit
|
|
16
17
|
|
|
17
18
|
from atsfill.core import browser, otp
|
|
18
19
|
from atsfill.core.adapter import Blocked, Gap, Unsupported
|
|
19
20
|
from atsfill.core.browser import HTTP_TIMEOUT_S, BrowserError, Page, Timeout
|
|
20
|
-
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
21
|
+
from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
|
|
21
22
|
|
|
22
23
|
HOSTS = re.compile(r"^(job-boards|boards)(\.eu)?\.greenhouse\.io$")
|
|
23
24
|
FORM = "#application-form"
|
|
@@ -29,6 +30,7 @@ EDUCATION_API = "https://boards-api.greenhouse.io/v1/boards/{board}/education/{k
|
|
|
29
30
|
EMBED = "https://job-boards.greenhouse.io/embed/job_app"
|
|
30
31
|
EMBED_TIMEOUT_MS = 15_000
|
|
31
32
|
OPTIONS_TIMEOUT_MS = 3_000
|
|
33
|
+
RACE_TIMEOUT_MS = 3_000
|
|
32
34
|
HYDRATE_TIMEOUT_MS = 15_000
|
|
33
35
|
FORM_TIMEOUT_MS = 20_000
|
|
34
36
|
ANSWER_TIMEOUT_MS = 15_000 # an upload's file name, a suggestion: past it, a mismatch
|
|
@@ -52,6 +54,7 @@ OTP_ERROR = "#email-verification-error"
|
|
|
52
54
|
# Sections the page adds or names its own way, or shows a question of only on another answer
|
|
53
55
|
# (EEOC race after Hispanic/Latino): `request_read` declines a posting with any (ADR 0011).
|
|
54
56
|
UNMAPPED = ("education", "compliance", "demographic_questions")
|
|
57
|
+
ETHNICITY, NOT_HISPANIC, RACE = "hispanic_ethnicity", "No", "race"
|
|
55
58
|
|
|
56
59
|
# The fields in page order. A select's options are read by opening it, from Python.
|
|
57
60
|
_READ = """form => {
|
|
@@ -196,10 +199,11 @@ class Greenhouse:
|
|
|
196
199
|
fields = [self._field(page, raw) for raw in raws]
|
|
197
200
|
self._fields = {f.id: f for f in fields}
|
|
198
201
|
self._kinds = {raw["id"]: raw["kind"] for raw in raws}
|
|
199
|
-
return fields
|
|
202
|
+
return self._with_race(page, fields)
|
|
200
203
|
|
|
201
204
|
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
202
|
-
|
|
205
|
+
# the page draws race only once the answer to Hispanic/Latino is in
|
|
206
|
+
for field_id, answer in sorted(answers.items(), key=lambda a: a[0] == RACE):
|
|
203
207
|
values = [answer] if isinstance(answer, str) else answer
|
|
204
208
|
match self._kinds[field_id]:
|
|
205
209
|
case "file":
|
|
@@ -235,6 +239,9 @@ class Greenhouse:
|
|
|
235
239
|
case _:
|
|
236
240
|
page.locator(_by_id(field_id)).fill(values[0])
|
|
237
241
|
|
|
242
|
+
def shown_fields(self, page: Page) -> list[str]:
|
|
243
|
+
return [raw["id"] for raw in page.locator(FORM).evaluate(_READ)]
|
|
244
|
+
|
|
238
245
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
239
246
|
values = page.locator(FORM).evaluate(_READ_BACK, list(self._fields))
|
|
240
247
|
return {k: v for k, v in values.items() if v not in (None, "", [])}
|
|
@@ -295,6 +302,30 @@ class Greenhouse:
|
|
|
295
302
|
one_of=raw.get("oneOf"),
|
|
296
303
|
)
|
|
297
304
|
|
|
305
|
+
def _with_race(self, page: Page, fields: list[Field]) -> list[Field]:
|
|
306
|
+
"""`fields` with the EEOC race question, a follow-up of Hispanic/Latino: its page's own
|
|
307
|
+
label and options show only with the question, so the read answers No, reads it and
|
|
308
|
+
clears the answer. The API's options are not the page's."""
|
|
309
|
+
ethnicity = self._fields.get(ETHNICITY)
|
|
310
|
+
if (
|
|
311
|
+
RACE in self._fields
|
|
312
|
+
or ethnicity is None
|
|
313
|
+
or NOT_HISPANIC not in (ethnicity.options or [])
|
|
314
|
+
):
|
|
315
|
+
return fields
|
|
316
|
+
_select(page, ETHNICITY, NOT_HISPANIC, False)
|
|
317
|
+
with suppress(Timeout):
|
|
318
|
+
page.locator(_by_id(RACE)).wait_for(timeout=RACE_TIMEOUT_MS)
|
|
319
|
+
raws = page.locator(FORM).evaluate(_READ)
|
|
320
|
+
raw = next((r for r in raws if r["id"] == RACE), None)
|
|
321
|
+
if raw is not None:
|
|
322
|
+
race = replace(self._field(page, raw), shown_when=Is(ETHNICITY, [NOT_HISPANIC]))
|
|
323
|
+
self._fields[RACE], self._kinds[RACE] = race, raw["kind"]
|
|
324
|
+
fields = [*fields]
|
|
325
|
+
fields.insert(fields.index(ethnicity) + 1, race)
|
|
326
|
+
_clear(page, ETHNICITY)
|
|
327
|
+
return fields
|
|
328
|
+
|
|
298
329
|
def _education(self, page: Page, field_id: str) -> list[str] | None:
|
|
299
330
|
"""An education select's options, every page of them, read once per kind; None for
|
|
300
331
|
another field or when the endpoint does not answer."""
|
|
@@ -429,6 +460,14 @@ def _exact(value: str) -> re.Pattern[str]:
|
|
|
429
460
|
return re.compile(rf"^\s*{re.escape(value)}\s*$")
|
|
430
461
|
|
|
431
462
|
|
|
463
|
+
def _clear(page: Page, field_id: str) -> None:
|
|
464
|
+
"""Empties a single react-select."""
|
|
465
|
+
box = page.locator(_by_id(field_id))
|
|
466
|
+
box.click()
|
|
467
|
+
box.press("Backspace")
|
|
468
|
+
box.press("Escape")
|
|
469
|
+
|
|
470
|
+
|
|
432
471
|
def _select(page: Page, field_id: str, value: str, suggested: bool) -> None:
|
|
433
472
|
"""Types into a react-select and picks the option: focused with the arrow keys then Enter,
|
|
434
473
|
which every select takes (a hover does not move the focus once typed). A suggestion list
|
|
@@ -16,7 +16,7 @@ from urllib.parse import urlsplit
|
|
|
16
16
|
|
|
17
17
|
from atsfill.core.adapter import Blocked, Gap, NotTaken, Unsupported
|
|
18
18
|
from atsfill.core.browser import Locator, Page, Timeout
|
|
19
|
-
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
19
|
+
from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
|
|
20
20
|
|
|
21
21
|
# The white-label domains HiringThing serves its career sites on, as freehire indexes them.
|
|
22
22
|
DOMAINS = (
|
|
@@ -56,6 +56,9 @@ SUBMIT_TIMEOUT_MS = 90_000
|
|
|
56
56
|
REQUIRED = re.compile(r"\s*\(required\)\s*$")
|
|
57
57
|
DATE = "%m/%d/%Y"
|
|
58
58
|
YES_NO = ["Yes", "No"]
|
|
59
|
+
SOURCE = "app.where_from"
|
|
60
|
+
SOURCE_DETAIL = "app.where_from_other" # rendered only once a source below is picked
|
|
61
|
+
DETAILED_SOURCES = ("Friend/Colleague", "Other")
|
|
59
62
|
TYPES = {
|
|
60
63
|
"text": FieldType.TEXT,
|
|
61
64
|
"date": FieldType.DATE,
|
|
@@ -176,10 +179,19 @@ class HiringThing:
|
|
|
176
179
|
questions = _questions(props.get_attribute("data-react-props") or "{}")
|
|
177
180
|
controls = form.evaluate(_CONTROLS)
|
|
178
181
|
self._kinds = {c["id"]: c["kind"] for c in controls if c["kind"] != "unhandled"}
|
|
179
|
-
|
|
182
|
+
fields = [
|
|
180
183
|
_field(c, questions, _menu(page, c["id"]) if c["kind"] == "select" else None)
|
|
181
184
|
for c in controls
|
|
182
185
|
]
|
|
186
|
+
source = next((f for f in fields if f.id == SOURCE), None)
|
|
187
|
+
if source and (detailed := [o for o in source.options or [] if o in DETAILED_SOURCES]):
|
|
188
|
+
self._kinds[SOURCE_DETAIL] = "text"
|
|
189
|
+
at = fields.index(source) + 1
|
|
190
|
+
fields.insert(at, _source_detail(detailed))
|
|
191
|
+
return fields
|
|
192
|
+
|
|
193
|
+
def shown_fields(self, page: Page) -> list[str]:
|
|
194
|
+
return [c["id"] for c in page.locator(FORM).evaluate(_CONTROLS)]
|
|
183
195
|
|
|
184
196
|
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
185
197
|
form = page.locator(FORM)
|
|
@@ -275,6 +287,12 @@ def _field(control: dict, questions: dict[str, str], options: list[str] | None)
|
|
|
275
287
|
return Field(field_id, label, TYPES[kind], required, options, max_length)
|
|
276
288
|
|
|
277
289
|
|
|
290
|
+
def _source_detail(sources: list[str]) -> Field:
|
|
291
|
+
"""What the page asks once one of `sources` is picked as the referral source."""
|
|
292
|
+
label = "Please Specify Source"
|
|
293
|
+
return Field(SOURCE_DETAIL, label, FieldType.TEXT, True, shown_when=Is(SOURCE, sources))
|
|
294
|
+
|
|
295
|
+
|
|
278
296
|
def _combobox(page: Page, field_id: str) -> Locator:
|
|
279
297
|
return page.locator(f"{FORM} input[role=combobox][id='{field_id}']")
|
|
280
298
|
|
|
@@ -190,6 +190,9 @@ class JazzHR:
|
|
|
190
190
|
case _:
|
|
191
191
|
field.fill(values[0])
|
|
192
192
|
|
|
193
|
+
def shown_fields(self, page: Page) -> list[str]:
|
|
194
|
+
return [raw["id"] for raw in page.locator(FORM).evaluate(_READ)]
|
|
195
|
+
|
|
193
196
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
194
197
|
values = page.locator(FORM).evaluate(_READ_BACK, self._fields)
|
|
195
198
|
return {k: v for k, v in values.items() if v not in (None, "", [])}
|
|
@@ -68,6 +68,19 @@ _READ_BACK = """(form, fields) => {
|
|
|
68
68
|
}
|
|
69
69
|
return out;
|
|
70
70
|
}"""
|
|
71
|
+
# The fields a form shows, by the names `_parse` gives them: its controls, a radio group once,
|
|
72
|
+
# and its documents, the older form's in forms of their own beside it.
|
|
73
|
+
_SHOWN = """form => {
|
|
74
|
+
const shown = el => el.getClientRects().length > 0;
|
|
75
|
+
const skipped = ["hidden", "submit", "button", "file"];
|
|
76
|
+
const names = [...form.querySelectorAll("input, select, textarea")]
|
|
77
|
+
.filter(el => el.name && !skipped.includes(el.type) && shown(el)).map(el => el.name);
|
|
78
|
+
const newer = [...form.querySelectorAll("[data-form-field-name]")]
|
|
79
|
+
.filter(shown).map(el => el.dataset.formFieldName);
|
|
80
|
+
const older = [...document.querySelectorAll("form[id^='js-'][id$='-form']")]
|
|
81
|
+
.filter(shown).map(el => el.id.slice("js-".length, -"-form".length));
|
|
82
|
+
return [...new Set([...names, ...newer, ...older])];
|
|
83
|
+
}"""
|
|
71
84
|
|
|
72
85
|
|
|
73
86
|
@dataclass
|
|
@@ -152,6 +165,12 @@ class JobAffinity:
|
|
|
152
165
|
if c.questionnaire:
|
|
153
166
|
_set(questions, c, answers[c.field.id])
|
|
154
167
|
|
|
168
|
+
def shown_fields(self, page: Page) -> list[str]:
|
|
169
|
+
shown = page.locator(NEW if self._form.new else OLD).evaluate(_SHOWN)
|
|
170
|
+
if self._questions:
|
|
171
|
+
shown += self._questions.locator(QUESTIONNAIRE).evaluate(_SHOWN)
|
|
172
|
+
return shown
|
|
173
|
+
|
|
155
174
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
156
175
|
main = [c for c in self._form.controls if not c.questionnaire]
|
|
157
176
|
values = _values(page, NEW if self._form.new else OLD, main)
|
|
@@ -92,6 +92,10 @@ _QUESTIONS = """() => {
|
|
|
92
92
|
}"""
|
|
93
93
|
|
|
94
94
|
|
|
95
|
+
# The questions the employer's step shows, by control name.
|
|
96
|
+
_ASKED = """() => [...document.querySelectorAll('[name^="questionnaire."]')].map(e => e.name)"""
|
|
97
|
+
|
|
98
|
+
|
|
95
99
|
class Jobstreet:
|
|
96
100
|
name = "jobstreet"
|
|
97
101
|
per_employer = True # one SEEK account, signed in on each site apart: the scope is the site
|
|
@@ -105,6 +109,7 @@ class Jobstreet:
|
|
|
105
109
|
]
|
|
106
110
|
|
|
107
111
|
def __init__(self) -> None:
|
|
112
|
+
self._asked: list[str] = [] # the employer's step's control names, once answered
|
|
108
113
|
self._fields: dict[str, Field] = {}
|
|
109
114
|
self._read: dict[str, Answer] = {}
|
|
110
115
|
|
|
@@ -198,6 +203,7 @@ class Jobstreet:
|
|
|
198
203
|
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
199
204
|
"""Walks the steps in order, each answered and left with Continue, which stays put with
|
|
200
205
|
its errors while one is missing; the review step takes the profile's visibility."""
|
|
206
|
+
self._asked = []
|
|
201
207
|
self._read = {k: f.prefilled for k in DETAILS if (f := self._fields[k]).prefilled}
|
|
202
208
|
self._read |= _personal_details(page, self._fields, answers)
|
|
203
209
|
_documents_fill(page, answers)
|
|
@@ -208,6 +214,7 @@ class Jobstreet:
|
|
|
208
214
|
for field_id, answer in answers.items():
|
|
209
215
|
if field_id.startswith("question."):
|
|
210
216
|
_answer(page, self._fields[field_id], answer)
|
|
217
|
+
self._asked = page.evaluate(_ASKED)
|
|
211
218
|
asked = page.evaluate(_QUESTIONS)
|
|
212
219
|
self._read |= {
|
|
213
220
|
f"question.{name.removeprefix('questionnaire.')}": value
|
|
@@ -232,6 +239,13 @@ class Jobstreet:
|
|
|
232
239
|
if radio.is_checked():
|
|
233
240
|
self._read["profileVisibility"] = radio.get_attribute("value") or ""
|
|
234
241
|
|
|
242
|
+
def shown_fields(self, page: Page) -> list[str]:
|
|
243
|
+
"""The questions the employer's step showed once answered: the wizard has left it. The
|
|
244
|
+
other steps' controls are the account's and the page's, read as `browser_read` lists
|
|
245
|
+
them."""
|
|
246
|
+
names = dict.fromkeys(self._asked)
|
|
247
|
+
return [f"question.{name.removeprefix('questionnaire.')}" for name in names]
|
|
248
|
+
|
|
235
249
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
236
250
|
return dict(self._read)
|
|
237
251
|
|
|
@@ -151,6 +151,7 @@ class Join:
|
|
|
151
151
|
self._fields: dict[str, Field] = {}
|
|
152
152
|
self._questions: list[dict[str, Any]] = []
|
|
153
153
|
self._seen: dict[str, Answer] = {} # each question as its step was left
|
|
154
|
+
self._walked: dict[str, None] = {} # the file and question fields of the steps walked
|
|
154
155
|
self._files: list[str] = [] # the file fields answered, in the steps' order
|
|
155
156
|
|
|
156
157
|
def detect(self, url: str, page: Page | None) -> bool:
|
|
@@ -253,15 +254,18 @@ class Join:
|
|
|
253
254
|
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
254
255
|
"""Walks the wizard from the step it is on to the review page, answering each step; a
|
|
255
256
|
step without a listed field (the account's name, its links) is left as it is."""
|
|
257
|
+
self._walked = {}
|
|
256
258
|
for _ in range(MAX_STEPS):
|
|
257
259
|
step = _step(page)
|
|
258
260
|
if step == "review":
|
|
259
261
|
page.locator("a > div > p + p").first.wait_for(timeout=STEP_TIMEOUT_MS)
|
|
260
262
|
return
|
|
261
263
|
if step in FILES:
|
|
264
|
+
self._walked[FILES[step]] = None
|
|
262
265
|
self._file(page, FILES[step], answers.get(FILES[step]))
|
|
263
266
|
elif question := self._step_question(step):
|
|
264
267
|
field_id = f"question.{question['id']}"
|
|
268
|
+
self._walked[field_id] = None
|
|
265
269
|
if field_id in self._fields:
|
|
266
270
|
self._question(page, question, answers.get(field_id))
|
|
267
271
|
elif step in PROFILE_STEPS:
|
|
@@ -289,6 +293,10 @@ class Join:
|
|
|
289
293
|
raise Unsupported(f"unhandled_rendering: a question step {step} not listed")
|
|
290
294
|
return None
|
|
291
295
|
|
|
296
|
+
def shown_fields(self, page: Page) -> list[str]:
|
|
297
|
+
"""The fields of the steps the fill walked: the wizard keeps only the step it is on."""
|
|
298
|
+
return list(self._walked)
|
|
299
|
+
|
|
292
300
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
293
301
|
"""The files as the review page lists them; each question as its step was left, since
|
|
294
302
|
the review shows a classed question's answer reworded (`€50,000`)."""
|
|
@@ -7,6 +7,7 @@ id. Nothing but the resume reaches Lever before submit, so the read-back is the
|
|
|
7
7
|
|
|
8
8
|
import re
|
|
9
9
|
from contextlib import suppress
|
|
10
|
+
from dataclasses import replace
|
|
10
11
|
from datetime import date
|
|
11
12
|
from typing import Any
|
|
12
13
|
from urllib.parse import urlsplit
|
|
@@ -14,7 +15,7 @@ from urllib.parse import urlsplit
|
|
|
14
15
|
from atsfill.core import browser
|
|
15
16
|
from atsfill.core.adapter import Blocked, Gap, Unsupported
|
|
16
17
|
from atsfill.core.browser import Locator, Page, Timeout
|
|
17
|
-
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
18
|
+
from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
|
|
18
19
|
|
|
19
20
|
HOSTS = re.compile(r"^jobs\.(eu\.)?lever\.co$")
|
|
20
21
|
JOB = re.compile(r"^/([^/]+)/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b")
|
|
@@ -26,6 +27,8 @@ SUGGEST_TIMEOUT_MS = 10_000
|
|
|
26
27
|
SUBMIT_TIMEOUT_MS = 90_000
|
|
27
28
|
KEY_DELAY_MS = 40
|
|
28
29
|
YES_NO = ["Yes", "No"]
|
|
30
|
+
DISABILITY = "eeo[disability]"
|
|
31
|
+
SIGNATURE = ("eeo[disabilitySignature]", "eeo[disabilitySignatureDate]") # shown by its answer
|
|
29
32
|
KINDS = {
|
|
30
33
|
"text": FieldType.TEXT,
|
|
31
34
|
"phone": FieldType.PHONE,
|
|
@@ -38,7 +41,8 @@ KINDS = {
|
|
|
38
41
|
"checkboxes": FieldType.MULTI_CHOICE,
|
|
39
42
|
}
|
|
40
43
|
|
|
41
|
-
# The fields in page order, one per control name: its question's label,
|
|
44
|
+
# The fields in page order, one per control name: its question's label, how it renders, and
|
|
45
|
+
# whether its question is on show.
|
|
42
46
|
_READ = """form => {
|
|
43
47
|
const text = el => {
|
|
44
48
|
if (!el) return "";
|
|
@@ -62,7 +66,7 @@ _READ = """form => {
|
|
|
62
66
|
const label = box.querySelector(".application-label .text") ||
|
|
63
67
|
box.querySelector(".application-label");
|
|
64
68
|
field = {id: name, label: text(label), required: !!box.querySelector(".required"),
|
|
65
|
-
options: [], count: 0};
|
|
69
|
+
options: [], count: 0, shown: box.getClientRects().length > 0};
|
|
66
70
|
if (el.type === "file") {
|
|
67
71
|
field.kind = "file";
|
|
68
72
|
} else if (el.tagName === "SELECT") {
|
|
@@ -153,14 +157,20 @@ class Lever:
|
|
|
153
157
|
except Timeout:
|
|
154
158
|
raise Unsupported("posting_gone: no application form on the page") from None
|
|
155
159
|
fields, self._kinds = [], {}
|
|
156
|
-
|
|
157
|
-
|
|
160
|
+
raws = form.evaluate(_READ)
|
|
161
|
+
asked = {raw["id"] for raw in raws}
|
|
162
|
+
for raw in raws:
|
|
163
|
+
field = _field(raw)
|
|
164
|
+
if raw["id"] in SIGNATURE and DISABILITY in asked:
|
|
165
|
+
field = replace(field, shown_when=Is(DISABILITY))
|
|
166
|
+
fields.append(field)
|
|
158
167
|
self._kinds[raw["id"]] = raw["kind"]
|
|
159
168
|
return fields
|
|
160
169
|
|
|
161
170
|
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
162
171
|
# the resume is parsed into the form, so it goes first and is waited for
|
|
163
|
-
|
|
172
|
+
first = sorted(answers.items(), key=lambda a: a[0] != "resume")
|
|
173
|
+
for name, answer in sorted(first, key=lambda a: a[0] in SIGNATURE):
|
|
164
174
|
values = [answer] if isinstance(answer, str) else answer
|
|
165
175
|
controls = page.locator(f"{FORM} [name={_quoted(name)}]:not([type=hidden])")
|
|
166
176
|
match self._kinds[name]:
|
|
@@ -183,6 +193,9 @@ class Lever:
|
|
|
183
193
|
case _:
|
|
184
194
|
controls.first.fill(values[0])
|
|
185
195
|
|
|
196
|
+
def shown_fields(self, page: Page) -> list[str]:
|
|
197
|
+
return [raw["id"] for raw in page.locator(FORM).evaluate(_READ) if raw["shown"]]
|
|
198
|
+
|
|
186
199
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
187
200
|
values = page.locator(FORM).evaluate(_READ_BACK, self._kinds)
|
|
188
201
|
for name, kind in self._kinds.items():
|
|
@@ -61,6 +61,23 @@ _READ = """fields => Object.fromEntries(fields.map(([id, kind, dom]) => {
|
|
|
61
61
|
default: return [id, el ? el.value || null : null];
|
|
62
62
|
}
|
|
63
63
|
}))"""
|
|
64
|
+
# The fields the form shows: a control by the field id of its element, else by its name, an
|
|
65
|
+
# Other text box by its list. A radio or a box sits unrendered under its styled mark, and
|
|
66
|
+
# select2 adds a search input of its own.
|
|
67
|
+
_SHOWN = """fields => {
|
|
68
|
+
const form = document.querySelector("#submit-id-submit").closest("form");
|
|
69
|
+
const rendered = el => el.getClientRects().length > 0;
|
|
70
|
+
const out = new Set();
|
|
71
|
+
for (const el of form.querySelectorAll("input, select, textarea")) {
|
|
72
|
+
if (["hidden", "submit", "button"].includes(el.type)) continue;
|
|
73
|
+
if (el.matches(".select2-search__field")) continue;
|
|
74
|
+
if (!rendered(el) && !rendered(el.parentElement)) continue;
|
|
75
|
+
if (el.matches(".custom-other-val-input"))
|
|
76
|
+
out.add(`${el.closest("li").querySelector("input[name]").name}.other`);
|
|
77
|
+
else out.add(fields[el.id] ?? (el.name || el.id));
|
|
78
|
+
}
|
|
79
|
+
return [...out];
|
|
80
|
+
}"""
|
|
64
81
|
# Selects the options with these labels on a select2 multiple select, which select2 follows.
|
|
65
82
|
_SELECT = """(el, labels) => {
|
|
66
83
|
for (const o of el.options) o.selected = labels.includes(o.text.trim());
|
|
@@ -290,6 +307,9 @@ class Manatal:
|
|
|
290
307
|
f"unhandled_rendering: cannot fill {field_id}, a {kind}, with {answer!r}"
|
|
291
308
|
)
|
|
292
309
|
|
|
310
|
+
def shown_fields(self, page: Page) -> list[str]:
|
|
311
|
+
return page.evaluate(_SHOWN, {dom: fid for fid, _, dom in _inputs(self._entries)})
|
|
312
|
+
|
|
293
313
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
294
314
|
inputs = [list(i) for i in _inputs(self._entries)]
|
|
295
315
|
if page.locator(f'input[name="{TERMS}"]').count():
|
|
@@ -4,7 +4,8 @@ as docs/adapters/oracle.md observes.
|
|
|
4
4
|
The form opens only past an email step, and an email the employer's Oracle already knows gets an
|
|
5
5
|
emailed OTP first, typed in by the core. Reading passes the step with a throwaway address;
|
|
6
6
|
filling starts over with the answer's. Oracle keeps a draft of the application from then on,
|
|
7
|
-
autosaved as fields change.
|
|
7
|
+
autosaved as fields change. The address follows the country's format, and which rows a country
|
|
8
|
+
shows is known only once it is picked: the fill reveals those of a country other than the form's.
|
|
8
9
|
"""
|
|
9
10
|
|
|
10
11
|
import re
|
|
@@ -42,6 +43,8 @@ ATTACHED = ".attachment-upload-button__download, .misc-attachment-list__item-nam
|
|
|
42
43
|
REMOVE = ".attachment-upload-button__filled .attachment-upload-button__bottom-button"
|
|
43
44
|
STEP_TIMEOUT_MS = 30_000
|
|
44
45
|
LIST_TIMEOUT_MS = 3_000
|
|
46
|
+
LIST_POLL_MS = 300 # between two looks at a list, which a search narrows as it answers
|
|
47
|
+
LIST_POLLS = 10
|
|
45
48
|
SEARCH_TIMEOUT_MS = 6_000 # for a typed search's results
|
|
46
49
|
PAGE_TIMEOUT_MS = 15_000 # per click on a page number
|
|
47
50
|
SUBMIT_TIMEOUT_MS = 90_000
|
|
@@ -115,7 +118,7 @@ _READ = """() => {
|
|
|
115
118
|
const named = row.querySelector("[id^=labelText-]");
|
|
116
119
|
const input = row.querySelector("input:not([type=hidden]), textarea");
|
|
117
120
|
let id = stable(named?.id || label?.getAttribute("for") || label?.id || input?.name);
|
|
118
|
-
const field = {id, label: text(row.querySelector(
|
|
121
|
+
const field = {id, address: !!row.closest(".address-form"), label: text(row.querySelector(
|
|
119
122
|
".input-row__linebreak, .input-row__label-text") || label),
|
|
120
123
|
required: !!label?.classList.contains("input-row__label--required")
|
|
121
124
|
|| !!row.querySelector("[aria-required=true], [required]")};
|
|
@@ -260,6 +263,8 @@ class Oracle:
|
|
|
260
263
|
self._kinds: dict[str, str] = {}
|
|
261
264
|
self._pages: dict[str, int] = {} # field id: the page of the form it is on
|
|
262
265
|
self._picked: dict[str, str] = {} # a geography field's id: the item picked in it
|
|
266
|
+
self._left_out: set[str] = set() # the geography levels the most specific one fills
|
|
267
|
+
self._filling = False # past the email step, with the answers given so far
|
|
263
268
|
self._months: list[str] = []
|
|
264
269
|
self._job = ""
|
|
265
270
|
|
|
@@ -284,7 +289,7 @@ class Oracle:
|
|
|
284
289
|
self._pass_email_step(
|
|
285
290
|
page, f"atsfill.read.{uuid.uuid4().hex[:12]}@example.com", bool(legal)
|
|
286
291
|
)
|
|
287
|
-
self._kinds, self._pages = {}, {}
|
|
292
|
+
self._kinds, self._pages, self._left_out, self._filling = {}, {}, set(), False
|
|
288
293
|
# The page numbers move between pages without checking the page left.
|
|
289
294
|
for number in range(1, max(len(_page_numbers(page)), 1) + 1):
|
|
290
295
|
_go_to(page, number)
|
|
@@ -293,13 +298,40 @@ class Oracle:
|
|
|
293
298
|
for raw in page.evaluate(_READ)
|
|
294
299
|
if raw["id"] not in self._kinds and (field := self._described(page, raw))
|
|
295
300
|
]
|
|
296
|
-
|
|
301
|
+
kept = _geography(described)
|
|
302
|
+
self._left_out |= {r["id"] for r, _ in described} - {f.id for _, f in kept}
|
|
303
|
+
for raw, field in _of_the_country(kept):
|
|
297
304
|
self._kinds[field.id], self._pages[field.id] = raw["kind"], number
|
|
298
305
|
fields.append(field)
|
|
299
306
|
self._fields = {f.id: f for f in fields}
|
|
300
307
|
return fields
|
|
301
308
|
|
|
309
|
+
def revealed(self, page: Page) -> list[Field]:
|
|
310
|
+
"""The fields of every page, once filled: an address row follows the country, which no
|
|
311
|
+
read lists but at a request per country, and a question can follow an answer."""
|
|
312
|
+
_settle(page)
|
|
313
|
+
for number in range(1, max(len(_page_numbers(page)), 1) + 1):
|
|
314
|
+
_go_to(page, number)
|
|
315
|
+
known = {*self._fields, *self._left_out}
|
|
316
|
+
described = [
|
|
317
|
+
(raw, field)
|
|
318
|
+
for raw in page.evaluate(_READ)
|
|
319
|
+
if raw["id"] not in known and (field := self._described(page, raw))
|
|
320
|
+
]
|
|
321
|
+
for raw, field in _geography(described):
|
|
322
|
+
self._kinds[field.id], self._pages[field.id] = raw["kind"], number
|
|
323
|
+
self._fields[field.id] = field
|
|
324
|
+
return list(self._fields.values())
|
|
325
|
+
|
|
302
326
|
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
327
|
+
"""Passes the email step under the answer's address and fills the form; once it did, what
|
|
328
|
+
`revealed` found, on the form as it stands."""
|
|
329
|
+
if not self._filling:
|
|
330
|
+
self._start(page, answers)
|
|
331
|
+
self._filling = True
|
|
332
|
+
self._fill_pages(page, answers)
|
|
333
|
+
|
|
334
|
+
def _start(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
303
335
|
if LEGAL in self._fields and answers.get(LEGAL) != "Yes":
|
|
304
336
|
raise Unsupported("not_qualified: the form opens only once the terms are accepted")
|
|
305
337
|
page.context.clear_cookies()
|
|
@@ -309,6 +341,8 @@ class Oracle:
|
|
|
309
341
|
email = answers.get(EMAIL)
|
|
310
342
|
assert isinstance(email, str) # required, so answered
|
|
311
343
|
self._pass_email_step(page, email, LEGAL in self._fields)
|
|
344
|
+
|
|
345
|
+
def _fill_pages(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
312
346
|
for number in sorted(set(self._pages.values())):
|
|
313
347
|
on_page = [i for i in answers if self._pages.get(i) == number]
|
|
314
348
|
if not on_page:
|
|
@@ -414,7 +448,7 @@ class Oracle:
|
|
|
414
448
|
"""The innermost row holding the field."""
|
|
415
449
|
selector = page.evaluate(_ROW, field_id)
|
|
416
450
|
if selector is None:
|
|
417
|
-
raise
|
|
451
|
+
raise NotTaken(f"{field_id} is not on the form under the answers given before it")
|
|
418
452
|
return page.locator(".input-row").filter(has=page.locator(selector)).last
|
|
419
453
|
|
|
420
454
|
def _options(self, page: Page, field_id: str) -> list[str]:
|
|
@@ -560,6 +594,24 @@ def _geography(described: list[tuple[dict, Field]]) -> list[tuple[dict, Field]]:
|
|
|
560
594
|
]
|
|
561
595
|
|
|
562
596
|
|
|
597
|
+
def _of_the_country(described: list[tuple[dict, Field]]) -> list[tuple[dict, Field]]:
|
|
598
|
+
"""Keys the address rows to the page's country: required under it alone, as another has rows
|
|
599
|
+
of its own, which the fill reveals, and a list's items its alone, another's a request each."""
|
|
600
|
+
country = next((f.default for _, f in described if f.id == COUNTRY), None)
|
|
601
|
+
if not isinstance(country, str):
|
|
602
|
+
return described
|
|
603
|
+
held = Is(COUNTRY, [country])
|
|
604
|
+
|
|
605
|
+
def keyed(field: Field) -> Field:
|
|
606
|
+
if field.required:
|
|
607
|
+
field = replace(field, required=False, required_when=held)
|
|
608
|
+
if field.options:
|
|
609
|
+
field = replace(field, options=None, option_sets=[OptionSet(held, field.options)])
|
|
610
|
+
return field
|
|
611
|
+
|
|
612
|
+
return [(r, keyed(f) if r.get("address") else f) for r, f in described]
|
|
613
|
+
|
|
614
|
+
|
|
563
615
|
def _page_numbers(page: Page) -> list[str]:
|
|
564
616
|
"""The page numbers of a form over several pages, none for a single page."""
|
|
565
617
|
return [_text(t) for t in page.locator(PAGE_NUMBERS).all_inner_texts() if _text(t).isdigit()]
|
|
@@ -617,6 +669,18 @@ def _open_list(page: Page, box: Locator) -> list[str]:
|
|
|
617
669
|
return options
|
|
618
670
|
|
|
619
671
|
|
|
672
|
+
def _list_settled(page: Page, cells: Locator) -> list[str]:
|
|
673
|
+
"""A list's items, once the search typed has stopped narrowing it."""
|
|
674
|
+
items = None
|
|
675
|
+
for _ in range(LIST_POLLS):
|
|
676
|
+
now = [_text(t) for t in cells.all_inner_texts()]
|
|
677
|
+
if now == items:
|
|
678
|
+
break
|
|
679
|
+
items = now
|
|
680
|
+
page.wait_for_timeout(LIST_POLL_MS)
|
|
681
|
+
return items or []
|
|
682
|
+
|
|
683
|
+
|
|
620
684
|
def _choose(
|
|
621
685
|
page: Page,
|
|
622
686
|
box: Locator,
|
|
@@ -637,15 +701,15 @@ def _choose(
|
|
|
637
701
|
cells = page.locator(f"[id='{box.get_attribute('aria-controls')}'] [role=gridcell]")
|
|
638
702
|
with suppress(browser.Timeout):
|
|
639
703
|
cells.first.wait_for(timeout=SEARCH_TIMEOUT_MS)
|
|
640
|
-
for
|
|
641
|
-
item = _text(cells.nth(i).inner_text())
|
|
704
|
+
for item in _list_settled(page, cells):
|
|
642
705
|
if (
|
|
643
706
|
(exact and item == answer)
|
|
644
707
|
or (code and re.search(rf"{re.escape(answer)}(\D|$)", item))
|
|
645
708
|
or (field is not None and matching.matches(field, answer, item))
|
|
646
709
|
):
|
|
647
|
-
cells.
|
|
648
|
-
|
|
710
|
+
cell = cells.filter(has_text=_exact(item)).first # by its text: a late search
|
|
711
|
+
cell.scroll_into_view_if_needed() # moves it, or leaves it alone in the list
|
|
712
|
+
_click(page, cell)
|
|
649
713
|
return
|
|
650
714
|
box.press("Escape")
|
|
651
715
|
if field is not None:
|
|
@@ -268,6 +268,10 @@ class Paycor:
|
|
|
268
268
|
f"unhandled_rendering: cannot fill {field.id}, a {kind}, with {answer!r}"
|
|
269
269
|
)
|
|
270
270
|
|
|
271
|
+
def shown_fields(self, page: Page) -> list[str]:
|
|
272
|
+
"""The form's controls; the answers to the pages before it are hidden inputs by now."""
|
|
273
|
+
return [c["id"] for c in page.evaluate(_CONTROLS)]
|
|
274
|
+
|
|
271
275
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
272
276
|
held: dict[str, Answer] = {}
|
|
273
277
|
for field, step in self._before:
|
|
@@ -111,6 +111,10 @@ class Personio:
|
|
|
111
111
|
case _:
|
|
112
112
|
control.fill(value)
|
|
113
113
|
|
|
114
|
+
def shown_fields(self, page: Page) -> list[str]:
|
|
115
|
+
form = html.parse(page.locator(FORM).evaluate("f => f.outerHTML"))
|
|
116
|
+
return [f.id for f, _ in _form(form)]
|
|
117
|
+
|
|
114
118
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
115
119
|
values = page.locator(FORM).evaluate(_READ_BACK, [f.id for f in self._fields])
|
|
116
120
|
out: dict[str, Answer] = {}
|
|
@@ -149,6 +149,10 @@ class Recruitee:
|
|
|
149
149
|
case _:
|
|
150
150
|
controls.first.fill(values[0])
|
|
151
151
|
|
|
152
|
+
def shown_fields(self, page: Page) -> list[str]:
|
|
153
|
+
form = html.parse(page.locator(FORM).evaluate("f => f.outerHTML"))
|
|
154
|
+
return [raw["id"] for raw in _read(form)]
|
|
155
|
+
|
|
152
156
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
153
157
|
values = page.locator(FORM).evaluate(_READ_BACK, self._kinds)
|
|
154
158
|
return {k: v for k, v in values.items() if v not in (None, "", [])}
|
|
@@ -159,6 +159,26 @@ _SHOWN = """(root, [id, nth, testids]) => {
|
|
|
159
159
|
return {kind: "unknown"};
|
|
160
160
|
}"""
|
|
161
161
|
|
|
162
|
+
# The fields the page shows, one `[data-testid=field]` block each, named by the first
|
|
163
|
+
# `data-testid` in it that names a field. A second block of one id is its "Other" text box.
|
|
164
|
+
_FIELDS = """(root, names) => {
|
|
165
|
+
const out = new Set();
|
|
166
|
+
const control = "input:not([type=hidden]), textarea, [role=combobox], [role=radio], "
|
|
167
|
+
+ "[role=checkbox]";
|
|
168
|
+
const generic = ["field", "screen-reader-only"];
|
|
169
|
+
for (const block of root.querySelectorAll("[data-testid=field]")) {
|
|
170
|
+
if (block.parentElement.closest("[data-testid=field]")) continue;
|
|
171
|
+
if (!block.getClientRects().length || !block.querySelector(control)) continue;
|
|
172
|
+
const el = [...block.querySelectorAll("[data-testid]")]
|
|
173
|
+
.find(e => !generic.includes(e.dataset.testid));
|
|
174
|
+
if (!el) continue;
|
|
175
|
+
const id = el.dataset.testid.replace(/_others$/, "_other").replace(/-code$/, "");
|
|
176
|
+
const name = names[id] ?? id;
|
|
177
|
+
out.add(out.has(name) ? `${name}_other` : name);
|
|
178
|
+
}
|
|
179
|
+
return [...out];
|
|
180
|
+
}"""
|
|
181
|
+
|
|
162
182
|
|
|
163
183
|
class Rippling:
|
|
164
184
|
name = "rippling"
|
|
@@ -236,9 +256,7 @@ class Rippling:
|
|
|
236
256
|
if field_id.endswith("_comment"):
|
|
237
257
|
_comment(page, field_id.removesuffix("_comment"), value)
|
|
238
258
|
elif field_id.endswith("_other"):
|
|
239
|
-
page
|
|
240
|
-
"input"
|
|
241
|
-
).fill(value)
|
|
259
|
+
_other(page, field_id.removesuffix("_other")).fill(value)
|
|
242
260
|
elif field_id == "phone_number":
|
|
243
261
|
_phone(page, value)
|
|
244
262
|
elif field_id == "location":
|
|
@@ -248,11 +266,15 @@ class Rippling:
|
|
|
248
266
|
else:
|
|
249
267
|
_fill(page, field_id, answer)
|
|
250
268
|
|
|
269
|
+
def shown_fields(self, page: Page) -> list[str]:
|
|
270
|
+
return page.locator("body").evaluate(_FIELDS, {v: k for k, v in TESTIDS.items()})
|
|
271
|
+
|
|
251
272
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
252
273
|
body, out = page.locator("body"), {}
|
|
253
274
|
for field_id in self._fields:
|
|
254
275
|
if field_id.endswith("_other"):
|
|
255
|
-
|
|
276
|
+
box = _other(page, field_id.removesuffix("_other"))
|
|
277
|
+
shown = {"value": box.input_value() if box.count() else None}
|
|
256
278
|
elif field_id == "phone_number":
|
|
257
279
|
code = page.locator("[data-testid='phone_number-code'] input").input_value()
|
|
258
280
|
number = page.locator("[data-testid='input-phone_number']").input_value()
|
|
@@ -358,6 +380,13 @@ def _box(page: Page, field_id: str) -> Locator:
|
|
|
358
380
|
return page.locator(f"[data-testid='{TESTIDS.get(field_id, field_id)}']").first
|
|
359
381
|
|
|
360
382
|
|
|
383
|
+
def _other(page: Page, field_id: str) -> Locator:
|
|
384
|
+
"""The text box an "Other" answer shows: `<id>_others` under boxes, a second `<id>` under
|
|
385
|
+
radios."""
|
|
386
|
+
second = page.locator(f"[data-testid='{field_id}']").nth(1)
|
|
387
|
+
return page.locator(f"[data-testid='{field_id}_others']").or_(second).locator("input")
|
|
388
|
+
|
|
389
|
+
|
|
361
390
|
def _option(page: Page, field_id: str, value: str) -> None:
|
|
362
391
|
"""Picks the open list's option whose text is `value`."""
|
|
363
392
|
option = page.locator("[role=listbox] [role=option]").filter(has_text=_exact(value))
|
|
@@ -439,6 +439,9 @@ class SuccessFactors:
|
|
|
439
439
|
raise NotTaken(f"{key} is no longer on the page")
|
|
440
440
|
return found | {"options": self._fields[key]["options"]}
|
|
441
441
|
|
|
442
|
+
def shown_fields(self, page: Page) -> list[str]:
|
|
443
|
+
return [d["key"] for d in page.evaluate(FIELDS)]
|
|
444
|
+
|
|
442
445
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
443
446
|
"""Each field's value; an attachment's, the file uploaded to it when it lists several."""
|
|
444
447
|
values = {}
|
|
@@ -35,7 +35,6 @@ SETTLE_MS = 500
|
|
|
35
35
|
YES_NO = ["Yes", "No"]
|
|
36
36
|
DATE_SHOWN = re.compile(r"(\d\d)/(\d\d)/(\d{4})")
|
|
37
37
|
COUNTRY, STATE = "Country", "State"
|
|
38
|
-
HISPANIC = "HispanicOrigin" # race (`EthnicOrigin`) is asked when not Hispanic or Latino
|
|
39
38
|
DECLINE_BOX = "input[type=checkbox][data-automation$='-decline-checkbox']"
|
|
40
39
|
DAYS, DOCUMENTS = "availability_days", "documents"
|
|
41
40
|
|
|
@@ -50,7 +49,9 @@ ERRORS = [
|
|
|
50
49
|
] # fmt: skip
|
|
51
50
|
|
|
52
51
|
# Each visible field of the form, in page order: a control's id (a radio group's name), its
|
|
53
|
-
# label, how it renders, whether it is required, its options and value.
|
|
52
|
+
# label, how it renders, whether it is required, its options and value. Two follow-ups are
|
|
53
|
+
# listed hidden, `unshown`, after the answer that shows them: race, when not Hispanic or Latino,
|
|
54
|
+
# and the referrer's details, when referred by an employee.
|
|
54
55
|
_READ = """root => {
|
|
55
56
|
const clean = s => (s || "").replace(/\\s+/g, " ").trim();
|
|
56
57
|
const shown = el => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length) &&
|
|
@@ -87,6 +88,7 @@ _READ = """root => {
|
|
|
87
88
|
const type = (el.type || "").toLowerCase();
|
|
88
89
|
if (["hidden", "file", "submit", "button"].includes(type) || !shown(el)) continue;
|
|
89
90
|
if (el.matches("[data-automation$='-decline-checkbox']")) continue; // its select's option
|
|
91
|
+
if (el.matches("[data-automation=select-document-checkbox]")) continue; // the upload's own
|
|
90
92
|
if (el.closest("ukg-input, ukg-input-container, [data-automation=upload-resume]")) continue;
|
|
91
93
|
const id = type === "radio" ? el.name : el.id || el.name || el.dataset.automation;
|
|
92
94
|
if (!id || seen.has(id)) continue;
|
|
@@ -131,8 +133,25 @@ _READ = """root => {
|
|
|
131
133
|
const label = labelOf(race);
|
|
132
134
|
out.splice(out.findIndex(f => f.id === "HispanicOrigin") + 1, 0, select(race, {
|
|
133
135
|
id: race.id, label: clean(label?.textContent), required: required(race, label),
|
|
134
|
-
readonly: false, maxLength: null,
|
|
135
|
-
shownWhen:
|
|
136
|
+
readonly: false, maxLength: null, unshown: !shown(race),
|
|
137
|
+
shownWhen: ["HispanicOrigin",
|
|
138
|
+
clean([...hispanic.options].find(o => o.value === "No")?.textContent)]}));
|
|
139
|
+
}
|
|
140
|
+
const referred = root.querySelector("input[type=radio][name=employeereferral][value=true]");
|
|
141
|
+
if (referred && out.some(f => f.id === "employeereferral")) {
|
|
142
|
+
const details = ["ReferralName", "ReferralEmail", "ReferralPhone"]
|
|
143
|
+
.map(id => root.querySelector(`input[id="${id}"]`)).filter(Boolean);
|
|
144
|
+
for (const el of details) {
|
|
145
|
+
const kept = out.findIndex(f => f.id === el.id);
|
|
146
|
+
if (kept >= 0) out.splice(kept, 1);
|
|
147
|
+
}
|
|
148
|
+
out.splice(out.findIndex(f => f.id === "employeereferral") + 1, 0, ...details.map(el => {
|
|
149
|
+
const label = labelOf(el);
|
|
150
|
+
return {id: el.id, label: clean(label?.textContent), required: required(el, label),
|
|
151
|
+
kind: "text", options: [], value: "", readonly: false, maxLength: el.maxLength,
|
|
152
|
+
unshown: !shown(el),
|
|
153
|
+
shownWhen: ["employeereferral", clean(referred.closest("label").textContent)]};
|
|
154
|
+
}));
|
|
136
155
|
}
|
|
137
156
|
const days = [...root.querySelectorAll("[data-automation=day-btn]")].filter(shown);
|
|
138
157
|
if (days.length) {
|
|
@@ -396,6 +415,9 @@ class UKG:
|
|
|
396
415
|
case _:
|
|
397
416
|
_control(root, field_id).fill(values[0])
|
|
398
417
|
|
|
418
|
+
def shown_fields(self, page: Page) -> list[str]:
|
|
419
|
+
return [raw["id"] for raw in page.locator(FORM).evaluate(_READ) if not raw.get("unshown")]
|
|
420
|
+
|
|
399
421
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
400
422
|
values = page.locator(FORM).evaluate(_READ_BACK, self._kinds)
|
|
401
423
|
return {k: v for k, v in values.items() if v not in (None, "", [])}
|
|
@@ -496,7 +518,8 @@ def _field(raw: dict[str, Any], states: dict[str, list[str]] | None) -> Field:
|
|
|
496
518
|
return Field(field_id, label, KINDS[kind], raw["required"], prefilled=value,
|
|
497
519
|
unfillable=reason) # fmt: skip
|
|
498
520
|
options = raw["options"] if kind in ("select", "radio") else None
|
|
499
|
-
|
|
521
|
+
when = raw.get("shownWhen")
|
|
522
|
+
shown_when = Is(when[0], [when[1]]) if when else None
|
|
500
523
|
if field_id == STATE and states is not None:
|
|
501
524
|
sets = [OptionSet(Is(COUNTRY, [country]), names) for country, names in states.items()]
|
|
502
525
|
return Field(field_id, label, FieldType.CHOICE, raw["required"], [], option_sets=sets)
|
|
@@ -179,6 +179,11 @@ class WelcomeKit:
|
|
|
179
179
|
case _:
|
|
180
180
|
control.fill(value)
|
|
181
181
|
|
|
182
|
+
def shown_fields(self, page: Page) -> list[str]:
|
|
183
|
+
"""The older form's controls; the newer form's API lists every field whatever the
|
|
184
|
+
answers, so it has nothing to tell."""
|
|
185
|
+
return [] if self._new else [c.field.id for c in _old_controls(page.content())]
|
|
186
|
+
|
|
182
187
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
183
188
|
form = page.locator("form").first if self._new else page.locator(OLD)
|
|
184
189
|
controls = [
|
|
@@ -49,6 +49,8 @@ DECLINE = "Skip for now" # its other button, Start my application, enrols the a
|
|
|
49
49
|
FORM = 'form:has([data-test="JobApplicationModal--SubmitButton"])'
|
|
50
50
|
SEND = '[data-test="JobApplicationModal--SubmitButton"]'
|
|
51
51
|
NOTE = "note"
|
|
52
|
+
NOTE_INPUT = "userNote"
|
|
53
|
+
QUESTION = re.compile(r"customQuestionAnswers\[(\d+)\]") # the start of a question's control name
|
|
52
54
|
GONE = "no longer available"
|
|
53
55
|
WRONG_PASSWORD = "Email or password is incorrect"
|
|
54
56
|
PASSWORD_LENGTH = (8, 128)
|
|
@@ -258,6 +260,12 @@ class Wellfound:
|
|
|
258
260
|
said = f"cannot fill {field_id}, a {kind}, with {answer!r}"
|
|
259
261
|
raise Unsupported(f"unhandled_rendering: {said}")
|
|
260
262
|
|
|
263
|
+
def shown_fields(self, page: Page) -> list[str]:
|
|
264
|
+
"""The dialog's controls, named as the read names its questions and its note."""
|
|
265
|
+
names = page.locator(FORM).evaluate("""form => [...form.querySelectorAll("[name]")]
|
|
266
|
+
.filter(e => e.getClientRects().length).map(e => e.name)""")
|
|
267
|
+
return list(dict.fromkeys(i for name in names if (i := _field_named(name))))
|
|
268
|
+
|
|
261
269
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
262
270
|
form = page.locator(FORM)
|
|
263
271
|
values: dict[str, Answer] = {}
|
|
@@ -373,6 +381,14 @@ def _opened(page: Page, read: list[dict[str, Any]]) -> dict[str, Any]:
|
|
|
373
381
|
raise RuntimeError("Apply opened no dialog")
|
|
374
382
|
|
|
375
383
|
|
|
384
|
+
def _field_named(name: str) -> str | None:
|
|
385
|
+
"""The field a control of the dialog belongs to."""
|
|
386
|
+
if name == NOTE_INPUT:
|
|
387
|
+
return NOTE
|
|
388
|
+
question = QUESTION.match(name)
|
|
389
|
+
return f"question_{question[1]}" if question else None
|
|
390
|
+
|
|
391
|
+
|
|
376
392
|
def _field(question: dict[str, Any]) -> Field:
|
|
377
393
|
field_id, label = f"question_{question['id']}", question["question"].strip()
|
|
378
394
|
required, options = question["required"], [o["option"] for o in question["options"]]
|
|
@@ -122,6 +122,9 @@ class WeRecruit:
|
|
|
122
122
|
case _:
|
|
123
123
|
control.fill(value)
|
|
124
124
|
|
|
125
|
+
def shown_fields(self, page: Page) -> list[str]:
|
|
126
|
+
return [c.field.id for c in _parse(page.content()) or []]
|
|
127
|
+
|
|
125
128
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
126
129
|
shown = [c for c in self._controls if not c.field.unfillable]
|
|
127
130
|
values = page.locator(FORM).evaluate(_READ_BACK, [c.name for c in shown])
|
|
@@ -243,6 +243,9 @@ class Workable:
|
|
|
243
243
|
case _:
|
|
244
244
|
field.fill(values[0])
|
|
245
245
|
|
|
246
|
+
def shown_fields(self, page: Page) -> list[str]:
|
|
247
|
+
return [raw["id"] for raw in page.locator(FORM).evaluate(_READ)]
|
|
248
|
+
|
|
246
249
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
247
250
|
values = page.locator(FORM).evaluate(_READ_BACK, self._kinds)
|
|
248
251
|
for field_id, fmt in self._formats.items():
|
|
@@ -97,6 +97,20 @@ _READ_BACK = """dialog => {
|
|
|
97
97
|
return out;
|
|
98
98
|
}"""
|
|
99
99
|
|
|
100
|
+
# The fields the dialog shows, once tagged; a control `_TAG` did not place, by its place in the
|
|
101
|
+
# dialog. The relocation box is the account profile's, not a field.
|
|
102
|
+
_SHOWN = """dialog => {
|
|
103
|
+
const out = new Set();
|
|
104
|
+
[...dialog.querySelectorAll("input, select, textarea")].forEach((el, n) => {
|
|
105
|
+
if (el.type === "hidden" || !el.getClientRects().length) return;
|
|
106
|
+
const block = el.closest("[data-atsfill-question]");
|
|
107
|
+
if (block) out.add("question_" + block.dataset.atsfillQuestion);
|
|
108
|
+
else if (el.dataset.atsfillField === "message") out.add("message");
|
|
109
|
+
else if (!el.dataset.atsfillField) out.add(`control #${n + 1}`);
|
|
110
|
+
});
|
|
111
|
+
return [...out];
|
|
112
|
+
}"""
|
|
113
|
+
|
|
100
114
|
|
|
101
115
|
class WorkAtAStartup:
|
|
102
116
|
name = "workatastartup"
|
|
@@ -261,6 +275,10 @@ class WorkAtAStartup:
|
|
|
261
275
|
if relocation.count():
|
|
262
276
|
relocation.uncheck()
|
|
263
277
|
|
|
278
|
+
def shown_fields(self, page: Page) -> list[str]:
|
|
279
|
+
self._tag(page)
|
|
280
|
+
return page.locator(DIALOG).first.evaluate(_SHOWN)
|
|
281
|
+
|
|
264
282
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
265
283
|
self._tag(page)
|
|
266
284
|
values = page.locator(DIALOG).first.evaluate(_READ_BACK)
|
|
@@ -322,8 +322,10 @@ class Workday:
|
|
|
322
322
|
_add_entries(page, answers)
|
|
323
323
|
filled: set[str] = set()
|
|
324
324
|
for _ in range(FOLLOW_UP_ROUNDS): # an answer shows its follow-up questions
|
|
325
|
-
todo = [r for r in _rendered(page, answers) if r["id"]
|
|
326
|
-
todo = [r for r in todo if r["id"] in
|
|
325
|
+
todo = [r for r in _rendered(page, answers) if r["id"] in answers]
|
|
326
|
+
todo = [r for r in todo if r["id"] in self._fields]
|
|
327
|
+
# a reply that came after its wait rendered over the answers given meanwhile
|
|
328
|
+
todo = [r for r in todo if r["id"] not in filled or _lost(r, answers[r["id"]])]
|
|
327
329
|
if not todo:
|
|
328
330
|
break
|
|
329
331
|
for raw in todo:
|
|
@@ -560,6 +562,11 @@ def _filled(page: Page, answers: dict[str, Answer]) -> list[dict[str, Any]]:
|
|
|
560
562
|
return raws
|
|
561
563
|
|
|
562
564
|
|
|
565
|
+
def _lost(raw: dict[str, Any], answer: Answer) -> bool:
|
|
566
|
+
"""Whether the page dropped a field's answer; an upload still attaching shows no value."""
|
|
567
|
+
return bool(answer) and raw["kind"] != "file" and raw["value"] in (None, "", [])
|
|
568
|
+
|
|
569
|
+
|
|
563
570
|
def _field(raw: dict[str, Any]) -> Field | None:
|
|
564
571
|
kind, options = KINDS.get(raw["kind"]), raw["options"]
|
|
565
572
|
if raw["kind"] == "box":
|
|
@@ -36,9 +36,10 @@ SUGGEST_POLL_MS = 250
|
|
|
36
36
|
KEY_DELAY_MS = 30
|
|
37
37
|
YES_NO = ["Yes", "No"]
|
|
38
38
|
|
|
39
|
-
# The fillable fields: an input's name is its id. Email comes from the account, disabled
|
|
39
|
+
# The fillable fields: an input's name is its id. Email comes from the account, disabled; an
|
|
40
|
+
# upload shows `save_resume`, whether the CV stays on the account, left as it is.
|
|
40
41
|
_READ = """form => [...form.querySelectorAll("input[name], textarea[name], select[name]")]
|
|
41
|
-
.filter(e => !e.disabled && e.name
|
|
42
|
+
.filter(e => !e.disabled && !["avatar", "save_resume"].includes(e.name))
|
|
42
43
|
.map(e => {
|
|
43
44
|
const label = form.querySelector(`label[for="${CSS.escape(e.id)}"]`);
|
|
44
45
|
return {id: e.name, tag: e.tagName.toLowerCase(), type: e.type,
|
|
@@ -172,6 +173,10 @@ class WelcomeToTheJungle:
|
|
|
172
173
|
)
|
|
173
174
|
raise Unsupported(reason)
|
|
174
175
|
|
|
176
|
+
def shown_fields(self, page: Page) -> list[str]:
|
|
177
|
+
raws = page.locator(FORM).first.evaluate(_READ)
|
|
178
|
+
return list(dict.fromkeys(r["id"] for r in raws if r["type"] != "hidden"))
|
|
179
|
+
|
|
175
180
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
176
181
|
values = page.locator(FORM).first.evaluate(_READ_BACK, list(self._fields))
|
|
177
182
|
return {k: v for k, v in values.items() if v not in (None, "")}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|