atsfill 0.0.4.dev61__tar.gz → 0.0.4.dev62__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.dev62}/PKG-INFO +1 -1
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/adapters/greenhouse.py +49 -10
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/LICENSE +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/README.md +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/src/atsfill/py.typed +0 -0
- {atsfill-0.0.4.dev61 → atsfill-0.0.4.dev62}/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.dev62
|
|
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")
|
|
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
|