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.
Files changed (30) hide show
  1. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/PKG-INFO +2 -2
  2. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/README.md +1 -1
  3. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/pyproject.toml +1 -1
  4. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/adapters/__init__.py +2 -0
  6. atsfill-0.0.4.dev29/src/atsfill/adapters/lever.py +252 -0
  7. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/registry.toml +2 -0
  8. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/LICENSE +0 -0
  9. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/__init__.py +0 -0
  10. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/adapters/ashby.py +0 -0
  11. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/adapters/greenhouse.py +0 -0
  12. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/adapters/manatal.py +0 -0
  13. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/adapters/oracle.py +0 -0
  14. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/adapters/paylocity.py +0 -0
  15. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/adapters/smartrecruiters.py +0 -0
  16. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/adapters/teamtailor.py +0 -0
  17. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/adapters/workable.py +0 -0
  18. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/adapters/wttj.py +0 -0
  19. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/cli.py +0 -0
  20. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/core/__init__.py +0 -0
  21. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/core/accounts.py +0 -0
  22. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/core/adapter.py +0 -0
  23. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/core/attempts.py +0 -0
  24. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/core/browser.py +0 -0
  25. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/core/commands.py +0 -0
  26. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/core/matching.py +0 -0
  27. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/core/paths.py +0 -0
  28. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/core/registry.py +0 -0
  29. {atsfill-0.0.4.dev28 → atsfill-0.0.4.dev29}/src/atsfill/core/results.py +0 -0
  30. {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.dev28
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
- ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![workable: dry run verified 2026-10-20](https://img.shields.io/badge/workable-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
56
+ ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![workable: dry run verified 2026-10-20](https://img.shields.io/badge/workable-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![lever: dry run verified 2026-10-20](https://img.shields.io/badge/lever-dry%20run%20verified%202026--10--20-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
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
- ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![workable: dry run verified 2026-10-20](https://img.shields.io/badge/workable-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
32
+ ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![workable: dry run verified 2026-10-20](https://img.shields.io/badge/workable-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![lever: dry run verified 2026-10-20](https://img.shields.io/badge/lever-dry%20run%20verified%202026--10--20-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
33
33
  <!-- /badges -->
34
34
 
35
35
  | System | Account needed | `read` without a browser |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.4.dev28"
3
+ version = "0.0.4.dev29"
4
4
  description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.4.dev28"
3
+ version = "0.0.4.dev29"
4
4
  description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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