atsfill 0.0.4.dev28__tar.gz → 0.0.4.dev29__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.dev28 → atsfill-0.0.4.dev29}/PKG-INFO +2 -2
- {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/README.md +1 -1
- {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/adapters/__init__.py +2 -0
- atsfill-0.0.4.dev29/src/atsfill/adapters/lever.py +252 -0
- {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/registry.toml +2 -0
- {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/LICENSE +0 -0
- {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/py.typed +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.dev29
|
|
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
|
|
@@ -53,7 +53,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
53
53
|
## Supported hiring systems
|
|
54
54
|
|
|
55
55
|
<!-- badges -->
|
|
56
|
-
        
|
|
56
|
+
         
|
|
57
57
|
<!-- /badges -->
|
|
58
58
|
|
|
59
59
|
| System | Account needed | `read` without a browser |
|
|
@@ -29,7 +29,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
29
29
|
## Supported hiring systems
|
|
30
30
|
|
|
31
31
|
<!-- badges -->
|
|
32
|
-
        
|
|
32
|
+
         
|
|
33
33
|
<!-- /badges -->
|
|
34
34
|
|
|
35
35
|
| System | Account needed | `read` without a browser |
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from atsfill.adapters.ashby import Ashby
|
|
2
2
|
from atsfill.adapters.greenhouse import Greenhouse
|
|
3
|
+
from atsfill.adapters.lever import Lever
|
|
3
4
|
from atsfill.adapters.manatal import Manatal
|
|
4
5
|
from atsfill.adapters.oracle import Oracle
|
|
5
6
|
from atsfill.adapters.paylocity import Paylocity
|
|
@@ -19,4 +20,5 @@ ADAPTERS: list[Adapter] = [
|
|
|
19
20
|
Manatal(),
|
|
20
21
|
Paylocity(),
|
|
21
22
|
Workable(),
|
|
23
|
+
Lever(),
|
|
22
24
|
]
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Lever (`jobs.lever.co` job pages and their application form), as docs/adapters/lever.md
|
|
2
|
+
observes.
|
|
3
|
+
|
|
4
|
+
The form is server-rendered HTML: every field is a named control, and its name is the field's
|
|
5
|
+
id. Nothing but the resume reaches Lever before submit, so the read-back is the page.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from contextlib import suppress
|
|
10
|
+
from datetime import date
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import urlsplit
|
|
13
|
+
|
|
14
|
+
from atsfill.core import browser
|
|
15
|
+
from atsfill.core.adapter import Blocked, Unsupported
|
|
16
|
+
from atsfill.core.browser import Locator, Page, Timeout
|
|
17
|
+
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
18
|
+
|
|
19
|
+
HOSTS = re.compile(r"^jobs\.(eu\.)?lever\.co$")
|
|
20
|
+
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")
|
|
21
|
+
FORM = "form#application-form"
|
|
22
|
+
FORM_TIMEOUT_MS = 30_000
|
|
23
|
+
UPLOAD_TIMEOUT_MS = 30_000
|
|
24
|
+
SUGGEST_TIMEOUT_MS = 10_000
|
|
25
|
+
SUBMIT_TIMEOUT_MS = 90_000
|
|
26
|
+
YES_NO = ["Yes", "No"]
|
|
27
|
+
KINDS = {
|
|
28
|
+
"text": FieldType.TEXT,
|
|
29
|
+
"phone": FieldType.PHONE,
|
|
30
|
+
"date": FieldType.DATE,
|
|
31
|
+
"location": FieldType.TYPEAHEAD,
|
|
32
|
+
"file": FieldType.FILE,
|
|
33
|
+
"select": FieldType.CHOICE,
|
|
34
|
+
"radio": FieldType.CHOICE,
|
|
35
|
+
"consent": FieldType.CHOICE,
|
|
36
|
+
"checkboxes": FieldType.MULTI_CHOICE,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# The fields in page order, one per control name: its question's label, and how it renders.
|
|
40
|
+
_READ = """form => {
|
|
41
|
+
const text = el => {
|
|
42
|
+
if (!el) return "";
|
|
43
|
+
const copy = el.cloneNode(true);
|
|
44
|
+
copy.querySelectorAll(".required, a, .description, .eeo-option-description, ul, select")
|
|
45
|
+
.forEach(e => e.remove());
|
|
46
|
+
return copy.textContent.replace(/\\s+/g, " ").trim();
|
|
47
|
+
};
|
|
48
|
+
const optionText = input => text(input.closest("label")?.querySelector(
|
|
49
|
+
".application-answer-alternative")) || input.value;
|
|
50
|
+
const found = new Map();
|
|
51
|
+
const controls = form.querySelectorAll(
|
|
52
|
+
"input[name]:not([type=hidden]):not([type=submit]), select[name], textarea[name]");
|
|
53
|
+
for (const el of controls) {
|
|
54
|
+
const name = el.name;
|
|
55
|
+
if (!name || el.id === "customPronounsTextField") continue;
|
|
56
|
+
const box = el.closest(".application-question, .application-additional");
|
|
57
|
+
if (!box) continue;
|
|
58
|
+
let field = found.get(name);
|
|
59
|
+
if (!field) {
|
|
60
|
+
const label = box.querySelector(".application-label .text") ||
|
|
61
|
+
box.querySelector(".application-label");
|
|
62
|
+
field = {id: name, label: text(label), required: !!box.querySelector(".required"),
|
|
63
|
+
options: [], count: 0};
|
|
64
|
+
if (el.type === "file") {
|
|
65
|
+
field.kind = "file";
|
|
66
|
+
} else if (el.tagName === "SELECT") {
|
|
67
|
+
field.kind = "select";
|
|
68
|
+
field.options = [...el.options].filter(o => o.value).map(o => o.textContent.trim());
|
|
69
|
+
} else if (el.type === "radio") field.kind = "radio";
|
|
70
|
+
else if (el.type === "checkbox") field.kind = name.startsWith("consent[") ? "consent" :
|
|
71
|
+
"checkboxes";
|
|
72
|
+
else if (name === "location") field.kind = "location";
|
|
73
|
+
else if (name === "phone") field.kind = "phone";
|
|
74
|
+
else if (el.placeholder === "MM/DD/YYYY") field.kind = "date";
|
|
75
|
+
else field.kind = "text";
|
|
76
|
+
field.maxLength = el.maxLength > 0 ? el.maxLength : null;
|
|
77
|
+
if (field.kind === "consent") field.label = text(el.closest("label"));
|
|
78
|
+
if (!field.label) field.label = el.placeholder ||
|
|
79
|
+
text(box.closest(".section")?.querySelector("h4"));
|
|
80
|
+
found.set(name, field);
|
|
81
|
+
}
|
|
82
|
+
field.required ||= el.required;
|
|
83
|
+
if (el.type === "radio" || el.type === "checkbox") field.options.push(optionText(el));
|
|
84
|
+
}
|
|
85
|
+
return [...found.values()];
|
|
86
|
+
}"""
|
|
87
|
+
|
|
88
|
+
# What each field shows: a choice its checked option's text, a select its option's text, a file
|
|
89
|
+
# its attached name.
|
|
90
|
+
_READ_BACK = """(form, kinds) => {
|
|
91
|
+
const optionText = input => (input.closest("label")?.querySelector(
|
|
92
|
+
".application-answer-alternative")?.textContent || input.value).replace(/\\s+/g, " ").trim();
|
|
93
|
+
const out = {};
|
|
94
|
+
for (const [name, kind] of Object.entries(kinds)) {
|
|
95
|
+
const all = [...form.querySelectorAll(`[name="${CSS.escape(name)}"]`)]
|
|
96
|
+
.filter(e => e.type !== "hidden");
|
|
97
|
+
if (!all.length) continue;
|
|
98
|
+
const el = all[0];
|
|
99
|
+
if (kind === "file") {
|
|
100
|
+
out[name] = el.closest("a, .application-field")?.querySelector(".filename")
|
|
101
|
+
?.textContent.trim() || null;
|
|
102
|
+
} else if (kind === "select") {
|
|
103
|
+
const option = el.selectedOptions[0];
|
|
104
|
+
out[name] = option && option.value ? option.textContent.trim() : null;
|
|
105
|
+
} else if (kind === "radio") {
|
|
106
|
+
const checked = all.find(e => e.checked);
|
|
107
|
+
out[name] = checked ? optionText(checked) : null;
|
|
108
|
+
} else if (kind === "checkboxes") {
|
|
109
|
+
out[name] = all.filter(e => e.type === "checkbox" && e.checked).map(optionText);
|
|
110
|
+
} else if (kind === "consent") out[name] = el.checked ? "Yes" : "No";
|
|
111
|
+
else out[name] = el.value;
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}"""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class Lever:
|
|
118
|
+
name = "lever"
|
|
119
|
+
side_effects = [
|
|
120
|
+
SideEffect("upload", "hiring_system", "the resume is uploaded to Lever and parsed at once"),
|
|
121
|
+
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
def __init__(self) -> None:
|
|
125
|
+
self._kinds: dict[str, str] = {} # how the page renders each field
|
|
126
|
+
|
|
127
|
+
def detect(self, url: str, page: Page | None) -> bool:
|
|
128
|
+
return _job(url) is not None
|
|
129
|
+
|
|
130
|
+
def posting(self, url: str) -> str:
|
|
131
|
+
parts, job = urlsplit(url), _job(url)
|
|
132
|
+
return f"https://{parts.hostname}/{job[0]}/{job[1]}" if job else url
|
|
133
|
+
|
|
134
|
+
def read_api(self, url: str) -> list[Field] | None:
|
|
135
|
+
return None # Lever's postings API leaves the application's questions out
|
|
136
|
+
|
|
137
|
+
def read_form(self, page: Page) -> list[Field]:
|
|
138
|
+
if not urlsplit(page.url).path.rstrip("/").endswith("/apply"):
|
|
139
|
+
page.goto(f"{self.posting(page.url)}/apply")
|
|
140
|
+
form = page.locator(FORM)
|
|
141
|
+
try:
|
|
142
|
+
form.wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
|
|
143
|
+
except Timeout:
|
|
144
|
+
raise Unsupported("posting_gone: no application form on the page") from None
|
|
145
|
+
fields, self._kinds = [], {}
|
|
146
|
+
for raw in form.evaluate(_READ):
|
|
147
|
+
fields.append(_field(raw))
|
|
148
|
+
self._kinds[raw["id"]] = raw["kind"]
|
|
149
|
+
return fields
|
|
150
|
+
|
|
151
|
+
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
152
|
+
# the resume is parsed into the form, so it goes first and is waited for
|
|
153
|
+
for name, answer in sorted(answers.items(), key=lambda a: a[0] != "resume"):
|
|
154
|
+
values = [answer] if isinstance(answer, str) else answer
|
|
155
|
+
controls = page.locator(f"{FORM} [name='{name}']:not([type=hidden])")
|
|
156
|
+
match self._kinds[name]:
|
|
157
|
+
case "file":
|
|
158
|
+
_attach(controls.first, values[0], resume=name == "resume")
|
|
159
|
+
case "location":
|
|
160
|
+
_locate(page, controls.first, values[0])
|
|
161
|
+
case "select":
|
|
162
|
+
controls.first.select_option(label=values[0])
|
|
163
|
+
case "radio" | "checkboxes":
|
|
164
|
+
for value in values:
|
|
165
|
+
option = _option(controls, value)
|
|
166
|
+
if option is not None:
|
|
167
|
+
option.check()
|
|
168
|
+
case "consent":
|
|
169
|
+
controls.first.set_checked(answer == "Yes")
|
|
170
|
+
case "date": # typed as its placeholder shows, MM/DD/YYYY
|
|
171
|
+
day = date.fromisoformat(values[0])
|
|
172
|
+
controls.first.fill(f"{day.month:02}/{day.day:02}/{day.year}")
|
|
173
|
+
case _:
|
|
174
|
+
controls.first.fill(values[0])
|
|
175
|
+
|
|
176
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
177
|
+
values = page.locator(FORM).evaluate(_READ_BACK, self._kinds)
|
|
178
|
+
for name, kind in self._kinds.items():
|
|
179
|
+
if kind == "date" and (
|
|
180
|
+
shown := re.fullmatch(r"(\d\d)/(\d\d)/(\d{4})", values.get(name) or "")
|
|
181
|
+
):
|
|
182
|
+
values[name] = f"{shown[3]}-{shown[1]}-{shown[2]}"
|
|
183
|
+
return {k: v for k, v in values.items() if v not in (None, "", [])}
|
|
184
|
+
|
|
185
|
+
def submit(self, page: Page) -> str | None:
|
|
186
|
+
"""Clicks submit; hCaptcha checks the visitor, then the form posts and Lever shows its
|
|
187
|
+
thanks page, the evidence."""
|
|
188
|
+
browser.click(page.locator(f"{FORM} #btn-submit"))
|
|
189
|
+
try:
|
|
190
|
+
page.wait_for_url(re.compile(r"/thanks\b"), timeout=SUBMIT_TIMEOUT_MS)
|
|
191
|
+
except Timeout:
|
|
192
|
+
if page.locator("iframe[src*='hcaptcha'][title*='challenge']").is_visible():
|
|
193
|
+
raise Blocked("captcha: hCaptcha asked for a challenge") from None
|
|
194
|
+
raise
|
|
195
|
+
return page.url
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _job(url: str) -> tuple[str, str] | None:
|
|
199
|
+
"""The account and posting id, from `jobs[.eu].lever.co/<account>/<id>[/apply]`."""
|
|
200
|
+
parts = urlsplit(url)
|
|
201
|
+
if not HOSTS.match(parts.hostname or ""):
|
|
202
|
+
return None
|
|
203
|
+
match = JOB.match(parts.path)
|
|
204
|
+
return (match[1], match[2]) if match else None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _field(raw: dict[str, Any]) -> Field:
|
|
208
|
+
kind = raw["kind"]
|
|
209
|
+
if kind not in KINDS:
|
|
210
|
+
raise Unsupported(f"form_shape: {raw['id']} is a {kind}")
|
|
211
|
+
options = {"consent": YES_NO, "select": raw["options"]}.get(kind)
|
|
212
|
+
if kind in ("radio", "checkboxes"):
|
|
213
|
+
options = raw["options"]
|
|
214
|
+
return Field(
|
|
215
|
+
raw["id"], raw["label"], KINDS[kind], raw["required"], options, raw.get("maxLength")
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _option(controls: Locator, value: str) -> Locator | None:
|
|
220
|
+
"""The radio or checkbox whose text is `value`."""
|
|
221
|
+
for control in controls.all():
|
|
222
|
+
label = control.locator("xpath=ancestor::label[1]")
|
|
223
|
+
shown = label.locator(".application-answer-alternative")
|
|
224
|
+
text = (shown.first.text_content() if shown.count() else None) or (
|
|
225
|
+
control.get_attribute("value") or ""
|
|
226
|
+
)
|
|
227
|
+
if " ".join(text.split()) == value:
|
|
228
|
+
return control
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _attach(control: Locator, path: str, resume: bool) -> None:
|
|
233
|
+
"""Attaches a file; the resume is uploaded and parsed at once, waited for until Lever says
|
|
234
|
+
it read it or could not."""
|
|
235
|
+
control.set_input_files(path)
|
|
236
|
+
if not resume:
|
|
237
|
+
return
|
|
238
|
+
box = control.locator("xpath=ancestor::li[1]")
|
|
239
|
+
with suppress(Timeout):
|
|
240
|
+
box.locator(".resume-upload-success, .resume-upload-failure").locator(
|
|
241
|
+
"visible=true"
|
|
242
|
+
).first.wait_for(timeout=UPLOAD_TIMEOUT_MS)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _locate(page: Page, control: Locator, value: str) -> None:
|
|
246
|
+
"""Types the location and picks Lever's first suggestion: the field takes only a pick."""
|
|
247
|
+
control.fill("")
|
|
248
|
+
control.press_sequentially(value, delay=40)
|
|
249
|
+
suggestion = page.locator(".dropdown-results .dropdown-location").first
|
|
250
|
+
with suppress(Timeout):
|
|
251
|
+
suggestion.wait_for(timeout=SUGGEST_TIMEOUT_MS)
|
|
252
|
+
suggestion.click()
|
|
@@ -192,7 +192,9 @@ kind = "ats"
|
|
|
192
192
|
share = 0.0116
|
|
193
193
|
account = "none"
|
|
194
194
|
hosting = ["hosted"]
|
|
195
|
+
adapter = "lever"
|
|
195
196
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
197
|
+
dry_run_verified = { date = 2026-10-20, version = "0.0.3", by = "gate", commit = "d35da0b" }
|
|
196
198
|
|
|
197
199
|
[adp]
|
|
198
200
|
kind = "ats"
|
|
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
|