atsfill 0.0.4.dev62__tar.gz → 0.0.4.dev64__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.dev62 → atsfill-0.0.4.dev64}/PKG-INFO +1 -1
- {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/adapters/manatal.py +51 -11
- {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/adapters/phenom.py +19 -9
- {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/LICENSE +0 -0
- {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/README.md +0 -0
- {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/src/atsfill/py.typed +0 -0
- {atsfill-0.0.4.dev62 → atsfill-0.0.4.dev64}/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.dev64
|
|
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
|
|
@@ -34,6 +34,9 @@ LISTS: dict[str, tuple[str, Callable[[dict[str, Any]], str]]] = {
|
|
|
34
34
|
"industries": ("clients/{client}/industries/", lambda n: n["name"]),
|
|
35
35
|
}
|
|
36
36
|
ENTRIES = ("educations", "experiences") # a sub-form of entries
|
|
37
|
+
# A salary's amount comes with a currency and a frequency select, each with an id of its own.
|
|
38
|
+
SALARIES = {"expected_salary": "expected", "current_salary": "current"}
|
|
39
|
+
FREQUENCIES = ["Hourly", "Daily", "Weekly", "Monthly", "Yearly"]
|
|
37
40
|
OTHER = "Other" # the radio or checkbox list option that opens a text box once picked
|
|
38
41
|
SHORT = {"full_name", "email", "phone_number", "city", "address", "latest_degree",
|
|
39
42
|
"latest_university", "current_company", "current_department", "current_position",
|
|
@@ -43,8 +46,8 @@ LOAD_TIMEOUT_MS = 30_000
|
|
|
43
46
|
SUBMIT_TIMEOUT_MS = 120_000
|
|
44
47
|
|
|
45
48
|
# The answer each input holds, by field id, read as the page reads them on Apply.
|
|
46
|
-
_READ = """fields => Object.fromEntries(fields.map(([id, kind]) => {
|
|
47
|
-
const el = document.getElementById(
|
|
49
|
+
_READ = """fields => Object.fromEntries(fields.map(([id, kind, dom]) => {
|
|
50
|
+
const el = document.getElementById(dom);
|
|
48
51
|
const named = [...document.querySelectorAll(`input[name="${id}"]`)];
|
|
49
52
|
const texts = s => [...s.selectedOptions].filter(o => o.value).map(o => o.text.trim());
|
|
50
53
|
switch (kind) {
|
|
@@ -107,6 +110,24 @@ def _kind(entry: dict[str, Any]) -> str:
|
|
|
107
110
|
return "text"
|
|
108
111
|
|
|
109
112
|
|
|
113
|
+
def _salary(entry: dict[str, Any]) -> list[tuple[str, str]]:
|
|
114
|
+
"""A salary's currency and frequency selects: the field id each is listed as, and its own id."""
|
|
115
|
+
prefix = SALARIES.get(entry["client_field_slug"])
|
|
116
|
+
if prefix is None or entry["field_type"] != "candidate_field":
|
|
117
|
+
return []
|
|
118
|
+
return [(f"{entry['id']}.{part}", f"{prefix}_{part}") for part in ("currency", "frequency")]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _inputs(entries: list[dict[str, Any]]) -> list[tuple[str, str, str]]:
|
|
122
|
+
"""Each input the page reads on Apply: its field id, its kind and its element id."""
|
|
123
|
+
inputs = []
|
|
124
|
+
for entry in entries:
|
|
125
|
+
if (kind := _kind(entry)) != "entries":
|
|
126
|
+
inputs.append((str(entry["id"]), kind, f"field{entry['id']}"))
|
|
127
|
+
inputs += [(fid, "select", dom) for fid, dom in _salary(entry)]
|
|
128
|
+
return inputs
|
|
129
|
+
|
|
130
|
+
|
|
110
131
|
def _field(entry: dict[str, Any], options: list[str] | None) -> Field:
|
|
111
132
|
"""The field an entry of the endpoint lists, a sub-form of entries aside."""
|
|
112
133
|
kind, slug = _kind(entry), entry["client_field_slug"]
|
|
@@ -182,7 +203,10 @@ class Manatal:
|
|
|
182
203
|
return None
|
|
183
204
|
entries = _get(f"jobs/{job.group(1)}/application-form/")
|
|
184
205
|
terms = bool(TERMS_REQUIRED.search(html))
|
|
185
|
-
|
|
206
|
+
salary = any(_salary(e) for e in entries)
|
|
207
|
+
currencies = [c["name"] for c in _get("currencies/")] if salary else []
|
|
208
|
+
salaries = lambda dom: currencies if dom.endswith("currency") else FREQUENCIES # noqa: E731
|
|
209
|
+
return self._fields(entries, terms, lambda e: _options(e, client), salaries)
|
|
186
210
|
except OSError, ValueError, KeyError, TypeError:
|
|
187
211
|
return None
|
|
188
212
|
|
|
@@ -199,19 +223,31 @@ class Manatal:
|
|
|
199
223
|
raise Unsupported(f"posting_gone: the form answered {response.status}")
|
|
200
224
|
self._entries = response.json()
|
|
201
225
|
page.wait_for_selector("#submit-id-submit", timeout=LOAD_TIMEOUT_MS)
|
|
202
|
-
# The lists of a multiple select come in their own requests, once the
|
|
226
|
+
# The lists of a multiple select and the currencies come in their own requests, once the
|
|
227
|
+
# form is drawn.
|
|
203
228
|
page.wait_for_function(
|
|
204
229
|
"() => [...document.querySelectorAll('select.multiselect')]"
|
|
205
|
-
".every(s => s.options.length && s.classList.contains('select2-hidden-accessible'))"
|
|
230
|
+
".every(s => s.options.length && s.classList.contains('select2-hidden-accessible'))"
|
|
231
|
+
" && [...document.querySelectorAll('#expected_currency, #current_currency')]"
|
|
232
|
+
".every(s => s.options.length)",
|
|
206
233
|
timeout=LOAD_TIMEOUT_MS,
|
|
207
234
|
)
|
|
208
235
|
terms = page.locator(f'input[name="{TERMS}"]').count() > 0
|
|
209
|
-
return self._fields(
|
|
236
|
+
return self._fields(
|
|
237
|
+
self._entries,
|
|
238
|
+
terms,
|
|
239
|
+
lambda e: self._shown(page, e),
|
|
240
|
+
lambda dom: page.locator(f"#{dom} option").evaluate_all(
|
|
241
|
+
"os => os.map(o => o.text.trim())"
|
|
242
|
+
),
|
|
243
|
+
)
|
|
210
244
|
|
|
211
245
|
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
212
|
-
|
|
246
|
+
inputs = _inputs(self._entries)
|
|
247
|
+
kinds = {fid: kind for fid, kind, _ in inputs} | {TERMS: "terms"}
|
|
248
|
+
doms = {fid: dom for fid, _, dom in inputs}
|
|
213
249
|
for field_id, answer in answers.items():
|
|
214
|
-
box = page.locator(f"#field{field_id}")
|
|
250
|
+
box = page.locator(f"#{doms.get(field_id, f'field{field_id}')}")
|
|
215
251
|
named = page.locator(f'input[name="{field_id}"]')
|
|
216
252
|
match kinds.get(field_id), answer:
|
|
217
253
|
case "text", str():
|
|
@@ -247,10 +283,10 @@ class Manatal:
|
|
|
247
283
|
)
|
|
248
284
|
|
|
249
285
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
250
|
-
|
|
286
|
+
inputs = [list(i) for i in _inputs(self._entries)]
|
|
251
287
|
if page.locator(f'input[name="{TERMS}"]').count():
|
|
252
|
-
|
|
253
|
-
held = page.evaluate(_READ,
|
|
288
|
+
inputs.append([TERMS, "terms", ""])
|
|
289
|
+
held = page.evaluate(_READ, inputs)
|
|
254
290
|
return {k: v for k, v in held.items() if v not in (None, [])}
|
|
255
291
|
|
|
256
292
|
def submit(self, page: Page) -> str | None:
|
|
@@ -271,6 +307,7 @@ class Manatal:
|
|
|
271
307
|
entries: list[dict[str, Any]],
|
|
272
308
|
terms: bool,
|
|
273
309
|
options: Callable[[dict[str, Any]], list[str] | None],
|
|
310
|
+
salaries: Callable[[str], list[str]],
|
|
274
311
|
) -> list[Field]:
|
|
275
312
|
fields = []
|
|
276
313
|
for entry in entries:
|
|
@@ -278,6 +315,9 @@ class Manatal:
|
|
|
278
315
|
fields.append(self._entries_field(entry))
|
|
279
316
|
continue
|
|
280
317
|
fields.append(field := _field(entry, options(entry)))
|
|
318
|
+
for fid, dom in _salary(entry):
|
|
319
|
+
label = f"{entry['label']}: {fid.rsplit('.', 1)[1]}"
|
|
320
|
+
fields.append(Field(fid, label, FieldType.CHOICE, field.required, salaries(dom)))
|
|
281
321
|
if _kind(entry) in ("radio", "checkbox") and OTHER in (field.options or []):
|
|
282
322
|
fields.append(self._other_field(field))
|
|
283
323
|
if terms:
|
|
@@ -311,6 +311,7 @@ class Phenom:
|
|
|
311
311
|
return None
|
|
312
312
|
|
|
313
313
|
def read_form(self, page: Page) -> list[Field]:
|
|
314
|
+
_check_open(page)
|
|
314
315
|
self._apply = _apply_url(page)
|
|
315
316
|
self._open(page)
|
|
316
317
|
try:
|
|
@@ -379,6 +380,7 @@ class Phenom:
|
|
|
379
380
|
page.evaluate("() => { localStorage.clear(); sessionStorage.clear(); }")
|
|
380
381
|
if fresh or not page.url.startswith(self._apply):
|
|
381
382
|
page.goto(self._apply, wait_until="domcontentloaded")
|
|
383
|
+
_check_open(page)
|
|
382
384
|
try:
|
|
383
385
|
page.locator(f"{FORM} {NEXT}, {FORM} button[type=submit]").first.wait_for(
|
|
384
386
|
state="attached", timeout=FORM_TIMEOUT_MS
|
|
@@ -421,17 +423,25 @@ def get(obj: Any, path: str) -> Any:
|
|
|
421
423
|
|
|
422
424
|
def _phapp_of(html: str) -> dict[str, Any]:
|
|
423
425
|
"""The apply page's `phApp`, from the object literals its scripts assign."""
|
|
426
|
+
ph = _literal(html, "var phApp = phApp || ")
|
|
427
|
+
ph["ddo"] = _literal(html, "phApp.ddo = ")
|
|
428
|
+
ph["sessionParams"] = _literal(html, "phApp.sessionParams = ")
|
|
429
|
+
return ph
|
|
424
430
|
|
|
425
|
-
def literal(marker: str) -> Any:
|
|
426
|
-
at = html.find(marker)
|
|
427
|
-
if at < 0:
|
|
428
|
-
raise ValueError(f"no {marker.strip()} on the page")
|
|
429
|
-
return json.JSONDecoder().raw_decode(html, at + len(marker))[0]
|
|
430
431
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
432
|
+
def _literal(html: str, marker: str) -> Any:
|
|
433
|
+
at = html.find(marker)
|
|
434
|
+
if at < 0:
|
|
435
|
+
raise ValueError(f"no {marker.strip()} on the page")
|
|
436
|
+
return json.JSONDecoder().raw_decode(html, at + len(marker))[0]
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _check_open(page: Page) -> None:
|
|
440
|
+
"""Raises `posting_gone` on Phenom's page for a closed posting, which a closed job's URL
|
|
441
|
+
and its wizard's both serve."""
|
|
442
|
+
with suppress(ValueError):
|
|
443
|
+
if _literal(page.content(), "var phApp = phApp || ").get("pageName") == "404":
|
|
444
|
+
raise Unsupported("posting_gone: the careers site answers its 404 page")
|
|
435
445
|
|
|
436
446
|
|
|
437
447
|
def _read(
|
|
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
|