atsfill 0.0.4.dev61__tar.gz → 0.0.4.dev63__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.dev61 → atsfill-0.0.4.dev63}/PKG-INFO +1 -1
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev63}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev63}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev63}/src/atsfill/adapters/greenhouse.py +49 -10
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev63}/src/atsfill/adapters/manatal.py +51 -11
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev63}/LICENSE +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev63}/README.md +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev63}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev63}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev63}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev63}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev63}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev63}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev63}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev63}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev63}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev63}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev63}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev63}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev63}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev63}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev63}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev63}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev63}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev63}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev63}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev63}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev63}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev63}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev63}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev63}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev63}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev63}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev63}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev63}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev63}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev63}/src/atsfill/py.typed +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev63}/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.dev63
|
|
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
|
|
@@ -5,6 +5,7 @@ Nothing but an attached file reaches Greenhouse before submit, so the read-back
|
|
|
5
5
|
input values, the shown value of each select, the phone's dial code, the attached file's name.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
import itertools
|
|
8
9
|
import json
|
|
9
10
|
import re
|
|
10
11
|
import time
|
|
@@ -21,6 +22,10 @@ from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
|
21
22
|
HOSTS = re.compile(r"^(job-boards|boards)(\.eu)?\.greenhouse\.io$")
|
|
22
23
|
FORM = "#application-form"
|
|
23
24
|
API = "https://boards-api.greenhouse.io/v1/boards/{board}/jobs/{job}?questions=true"
|
|
25
|
+
# An education row's selects (`school--0`, …) and the endpoint listing their options a hundred
|
|
26
|
+
# per page: the opened menu shows only the first page.
|
|
27
|
+
EDUCATION = re.compile(r"^(school|degree|discipline)--\d+$")
|
|
28
|
+
EDUCATION_API = "https://boards-api.greenhouse.io/v1/boards/{board}/education/{kind}s?page={page}"
|
|
24
29
|
EMBED = "https://job-boards.greenhouse.io/embed/job_app"
|
|
25
30
|
EMBED_TIMEOUT_MS = 15_000
|
|
26
31
|
OPTIONS_TIMEOUT_MS = 3_000
|
|
@@ -130,6 +135,7 @@ class Greenhouse:
|
|
|
130
135
|
self._fields: dict[str, Field] = {}
|
|
131
136
|
self._kinds: dict[str, str] = {} # how the page renders each field
|
|
132
137
|
self._embed: str | None = None # the job_app URL an employer page embeds
|
|
138
|
+
self._education_options: dict[str, list[str] | None] = {} # by school, degree, discipline
|
|
133
139
|
|
|
134
140
|
def detect(self, url: str, page: Page | None) -> bool:
|
|
135
141
|
"""A board URL, or an employer page whose `#grnhse_iframe` embeds a job application."""
|
|
@@ -162,11 +168,7 @@ class Greenhouse:
|
|
|
162
168
|
if found is None:
|
|
163
169
|
return None
|
|
164
170
|
try:
|
|
165
|
-
|
|
166
|
-
API.format(board=found[0], job=found[1]), headers={"User-Agent": "atsfill"}
|
|
167
|
-
)
|
|
168
|
-
with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
|
|
169
|
-
return _api_fields(json.load(response))
|
|
171
|
+
return _api_fields(_get_json(API.format(board=found[0], job=found[1])))
|
|
170
172
|
except OSError, ValueError, KeyError, TypeError:
|
|
171
173
|
return None
|
|
172
174
|
|
|
@@ -236,7 +238,7 @@ class Greenhouse:
|
|
|
236
238
|
case "checkboxes":
|
|
237
239
|
field_type = FieldType.MULTI_CHOICE
|
|
238
240
|
case "select" | "multi":
|
|
239
|
-
options = self._options(page, raw["id"])
|
|
241
|
+
options = self._education(page, raw["id"]) or self._options(page, raw["id"])
|
|
240
242
|
if options is None:
|
|
241
243
|
field_type = FieldType.TYPEAHEAD
|
|
242
244
|
else:
|
|
@@ -256,6 +258,17 @@ class Greenhouse:
|
|
|
256
258
|
raw.get("accept"),
|
|
257
259
|
)
|
|
258
260
|
|
|
261
|
+
def _education(self, page: Page, field_id: str) -> list[str] | None:
|
|
262
|
+
"""An education select's options, every page of them, read once per kind; None for
|
|
263
|
+
another field or when the endpoint does not answer."""
|
|
264
|
+
found = _board_job(page.url)
|
|
265
|
+
if not (found and (match := EDUCATION.match(field_id))):
|
|
266
|
+
return None
|
|
267
|
+
kind = match[1]
|
|
268
|
+
if kind not in self._education_options:
|
|
269
|
+
self._education_options[kind] = _education_options(found[0], kind)
|
|
270
|
+
return self._education_options[kind]
|
|
271
|
+
|
|
259
272
|
def _options(self, page: Page, field_id: str) -> list[str] | None:
|
|
260
273
|
"""A select's options, from opening it; None for one that suggests as you type."""
|
|
261
274
|
box = page.locator(_by_id(field_id))
|
|
@@ -302,6 +315,26 @@ def _board_job(url: str) -> tuple[str, str] | None:
|
|
|
302
315
|
return None
|
|
303
316
|
|
|
304
317
|
|
|
318
|
+
def _get_json(url: str) -> Any:
|
|
319
|
+
request = urllib.request.Request(url, headers={"User-Agent": "atsfill"})
|
|
320
|
+
with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
|
|
321
|
+
return json.load(response)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _education_options(board: str, kind: str) -> list[str] | None:
|
|
325
|
+
"""Every option the board's education endpoint lists for `kind`, page after page."""
|
|
326
|
+
options: list[str] = []
|
|
327
|
+
try:
|
|
328
|
+
for number in itertools.count(1):
|
|
329
|
+
body = _get_json(EDUCATION_API.format(board=board, kind=kind, page=number))
|
|
330
|
+
options += [item["text"] for item in body["items"]]
|
|
331
|
+
if not body["items"] or len(options) >= body["meta"]["total_count"]:
|
|
332
|
+
return options or None
|
|
333
|
+
except OSError, ValueError, KeyError, TypeError:
|
|
334
|
+
return None
|
|
335
|
+
return None
|
|
336
|
+
|
|
337
|
+
|
|
305
338
|
def _api_fields(job: dict[str, Any]) -> list[Field] | None:
|
|
306
339
|
if any(job.get(k) for k in UNMAPPED) or any(
|
|
307
340
|
c.get("requires_consent") or c.get("requires_processing_consent")
|
|
@@ -336,17 +369,23 @@ def _exact(value: str) -> re.Pattern[str]:
|
|
|
336
369
|
|
|
337
370
|
|
|
338
371
|
def _select(page: Page, field_id: str, value: str, suggested: bool) -> None:
|
|
339
|
-
"""Types into a react-select and picks the option:
|
|
340
|
-
|
|
372
|
+
"""Types into a react-select and picks the option: focused with the arrow keys then Enter,
|
|
373
|
+
which every select takes (a hover does not move the focus once typed). A suggestion list
|
|
374
|
+
takes its first suggestion. No option leaves the select empty."""
|
|
341
375
|
box = page.locator(_by_id(field_id))
|
|
342
376
|
box.click()
|
|
343
377
|
box.press_sequentially(value, delay=KEY_DELAY_MS)
|
|
344
|
-
|
|
378
|
+
listbox = _by_id(f"react-select-{field_id}-listbox")
|
|
379
|
+
menu = page.locator(f"{listbox} [role=option]")
|
|
345
380
|
option = menu.first if suggested else menu.filter(has_text=_exact(value)).first
|
|
346
381
|
try:
|
|
347
382
|
option.wait_for(timeout=ANSWER_TIMEOUT_MS)
|
|
348
383
|
except Timeout:
|
|
349
384
|
box.press("Escape")
|
|
350
385
|
return
|
|
351
|
-
|
|
386
|
+
focused = page.locator(f"{listbox} .select__option--is-focused")
|
|
387
|
+
for _ in range(menu.count()):
|
|
388
|
+
if suggested or any(_exact(value).match(t) for t in focused.all_inner_texts()):
|
|
389
|
+
break
|
|
390
|
+
box.press("ArrowDown")
|
|
352
391
|
box.press("Enter")
|
|
@@ -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:
|
|
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
|