atsfill 0.0.3.dev13__tar.gz → 0.0.3.dev16__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.3.dev13 → atsfill-0.0.3.dev16}/PKG-INFO +2 -2
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/README.md +1 -1
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/pyproject.toml +1 -1
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/pyproject.toml.orig +1 -1
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/src/atsfill/adapters/teamtailor.py +23 -10
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/src/atsfill/adapters/wttj.py +32 -15
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/src/atsfill/core/matching.py +1 -1
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/src/atsfill/core/results.py +1 -0
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/src/atsfill/registry.toml +3 -0
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/LICENSE +0 -0
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/src/atsfill/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: atsfill
|
|
3
|
-
Version: 0.0.3.
|
|
3
|
+
Version: 0.0.3.dev16
|
|
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
|
-
     
|
|
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
|
-
     
|
|
33
33
|
<!-- /badges -->
|
|
34
34
|
|
|
35
35
|
| System | Account needed | `read` without a browser |
|
|
@@ -7,6 +7,8 @@ soon as it is picked. The read-back is the form's `FormData`.
|
|
|
7
7
|
|
|
8
8
|
import re
|
|
9
9
|
import time
|
|
10
|
+
import unicodedata
|
|
11
|
+
from pathlib import PurePath
|
|
10
12
|
from typing import Any
|
|
11
13
|
from urllib.parse import urlsplit
|
|
12
14
|
|
|
@@ -164,6 +166,7 @@ class Teamtailor:
|
|
|
164
166
|
self._fields: dict[str, Field] = {}
|
|
165
167
|
self._qualifying: set[str] = set()
|
|
166
168
|
self._follow_ups: dict[str, bool] = {} # shown by an earlier answer: required once shown
|
|
169
|
+
self._uploads: dict[str, str] = {} # file field id: the answer's file name
|
|
167
170
|
|
|
168
171
|
def detect(self, url: str, page: Page | None) -> bool:
|
|
169
172
|
"""A `*.teamtailor.com` job, or a job page with Teamtailor's "powered by" link or its
|
|
@@ -225,15 +228,23 @@ class Teamtailor:
|
|
|
225
228
|
if required and field_id not in answers and self._shown(form, field_id):
|
|
226
229
|
label = self._fields[field_id].label
|
|
227
230
|
raise Unsupported(f"missing_answer: an earlier answer asks {label!r}")
|
|
228
|
-
self.
|
|
231
|
+
self._uploads = {
|
|
232
|
+
i: PurePath(a).name
|
|
233
|
+
for i, a in answers.items()
|
|
234
|
+
if self._fields[i].type is FieldType.FILE and isinstance(a, str)
|
|
235
|
+
}
|
|
236
|
+
self._wait_for_uploads(form)
|
|
229
237
|
|
|
230
238
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
231
239
|
values = page.locator(FORM).evaluate(_READ_BACK, QUESTION)
|
|
240
|
+
for field_id, name in self._uploads.items():
|
|
241
|
+
if values.get(field_id) == _stored_name(name):
|
|
242
|
+
values[field_id] = name
|
|
232
243
|
return {k: v for k, v in values.items() if v not in (None, "", [])}
|
|
233
244
|
|
|
234
245
|
def submit(self, page: Page) -> str | None:
|
|
235
246
|
_click(page, page.locator(f"{FORM} [type=submit]"))
|
|
236
|
-
page.wait_for_url(re.compile(r"/applications
|
|
247
|
+
page.wait_for_url(re.compile(r"/applications/[^/]+/thanks"), timeout=SUBMIT_TIMEOUT_MS)
|
|
237
248
|
return page.url
|
|
238
249
|
|
|
239
250
|
def _fill(self, form: Locator, field_id: str, answer: Answer) -> None:
|
|
@@ -290,20 +301,22 @@ class Teamtailor:
|
|
|
290
301
|
)
|
|
291
302
|
return form.locator(f"[name='{field_id}']").first.locator("xpath=..")
|
|
292
303
|
|
|
293
|
-
def _wait_for_uploads(self, form: Locator
|
|
294
|
-
files = {
|
|
295
|
-
i: a.rsplit("/", 1)[-1]
|
|
296
|
-
for i, a in answers.items()
|
|
297
|
-
if self._fields[i].type is FieldType.FILE and isinstance(a, str)
|
|
298
|
-
}
|
|
304
|
+
def _wait_for_uploads(self, form: Locator) -> None:
|
|
299
305
|
deadline = time.monotonic() + UPLOAD_TIMEOUT_S
|
|
300
|
-
while
|
|
306
|
+
while self._uploads and time.monotonic() < deadline:
|
|
301
307
|
held = form.evaluate(_READ_BACK, QUESTION)
|
|
302
|
-
if all(held.get(i) ==
|
|
308
|
+
if all(held.get(i) == _stored_name(n) for i, n in self._uploads.items()):
|
|
303
309
|
return
|
|
304
310
|
form.page.wait_for_timeout(500)
|
|
305
311
|
|
|
306
312
|
|
|
313
|
+
def _stored_name(name: str) -> str:
|
|
314
|
+
"""The name Teamtailor stores a file under: ASCII, lowercase, each run of other
|
|
315
|
+
characters than letters, digits, `.` and `-` one `_`."""
|
|
316
|
+
ascii_name = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode()
|
|
317
|
+
return re.sub(r"[^a-z0-9.\-]+", "_", ascii_name.lower())
|
|
318
|
+
|
|
319
|
+
|
|
307
320
|
def _field(raw: dict[str, Any]) -> Field | None:
|
|
308
321
|
"""The field a raw description is, None for a kind atsfill cannot fill."""
|
|
309
322
|
kinds = QUESTION_TYPES if raw.get("question") else STANDARD_TYPES
|
|
@@ -17,11 +17,14 @@ from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
|
17
17
|
|
|
18
18
|
HOST = "www.welcometothejungle.com"
|
|
19
19
|
JOB = re.compile(r"^/(?:[a-z]{2}/)?companies/([^/]+)/jobs/([^/?#]+)")
|
|
20
|
-
FORM = "form:has([
|
|
20
|
+
FORM = "form:has([data-testid=apply-form-submit])"
|
|
21
21
|
ME = "https://api.welcometothejungle.com/api/v2/users/me"
|
|
22
22
|
FORM_TIMEOUT_MS = 20_000
|
|
23
23
|
SIGNED_IN_TIMEOUT_S = 10
|
|
24
24
|
SUBMIT_TIMEOUT_MS = 90_000
|
|
25
|
+
SUGGEST_TRIES = 3
|
|
26
|
+
LOCATION_TIMEOUT_S = 3
|
|
27
|
+
SUGGEST_TIMEOUT_S = 5
|
|
25
28
|
YES_NO = ["Yes", "No"]
|
|
26
29
|
|
|
27
30
|
# The fillable fields: an input's name is its id. Email comes from the account, disabled.
|
|
@@ -33,7 +36,9 @@ _READ = """form => [...form.querySelectorAll("input[name], textarea[name]")]
|
|
|
33
36
|
label: (label ?? e.closest("fieldset")?.querySelector("legend"))?.textContent
|
|
34
37
|
.replace(/\\s+/g, " ").trim() ?? "",
|
|
35
38
|
required: e.required || label?.hasAttribute("required"),
|
|
36
|
-
accept: e.accept ? e.accept.split(",") : null
|
|
39
|
+
accept: e.accept ? e.accept.split(",") : null,
|
|
40
|
+
prefilled: e.type === "checkbox" ? e.checked
|
|
41
|
+
: e.type === "file" ? !!e.parentElement.querySelector("[data-id]") : !!e.value};
|
|
37
42
|
})"""
|
|
38
43
|
|
|
39
44
|
_READ_BACK = """(form, ids) => Object.fromEntries(ids.map(id => {
|
|
@@ -98,6 +103,10 @@ class WelcomeToTheJungle:
|
|
|
98
103
|
form.wait_for(timeout=FORM_TIMEOUT_MS)
|
|
99
104
|
except Timeout:
|
|
100
105
|
raise Unsupported("form_shape: the apply button opened no form") from None
|
|
106
|
+
location = form.locator("[name=location]")
|
|
107
|
+
deadline = time.monotonic() + LOCATION_TIMEOUT_S
|
|
108
|
+
while location.count() and not location.input_value() and time.monotonic() < deadline:
|
|
109
|
+
page.wait_for_timeout(200) # the profile's location comes last, a second later
|
|
101
110
|
fields = []
|
|
102
111
|
for raw in form.evaluate(_READ):
|
|
103
112
|
field = _field(raw)
|
|
@@ -114,11 +123,12 @@ class WelcomeToTheJungle:
|
|
|
114
123
|
target = form.locator(f'[name="{field_id}"]')
|
|
115
124
|
match self._fields[field_id].type, answer:
|
|
116
125
|
case FieldType.FILE, str():
|
|
117
|
-
|
|
126
|
+
box = target.locator("xpath=..")
|
|
127
|
+
remove = box.locator("button[title=Remove]")
|
|
118
128
|
if remove.count():
|
|
119
129
|
remove.click() # the profile's CV; never made the default
|
|
120
130
|
target.set_input_files(answer)
|
|
121
|
-
|
|
131
|
+
box.locator(f'[data-id="{answer.rsplit("/", 1)[-1]}"]').wait_for()
|
|
122
132
|
case FieldType.CHOICE, str():
|
|
123
133
|
if target.is_checked() != (answer == "Yes"):
|
|
124
134
|
browser.click(form.locator(f'label[for="{target.get_attribute("id")}"]'))
|
|
@@ -149,17 +159,21 @@ class WelcomeToTheJungle:
|
|
|
149
159
|
|
|
150
160
|
def _suggest(page: Page, field: Field, box: Locator, answer: str) -> None:
|
|
151
161
|
"""Retypes the answer key by key, which loads the suggestions, and picks the first that
|
|
152
|
-
matches it, never "current position" (the item with an icon, which geolocates).
|
|
153
|
-
|
|
154
|
-
box.press("Control+a") # `fill("")`, select-all and Delete, leaves the profile's value
|
|
155
|
-
box.press("Backspace")
|
|
156
|
-
box.press_sequentially(answer, delay=30)
|
|
162
|
+
matches it, never "current position" (the item with an icon, which geolocates). Typed too
|
|
163
|
+
early, the answer loads no suggestions at all: it is typed again."""
|
|
157
164
|
options = page.locator("[role=option]:visible").filter(has_not=page.locator("svg"))
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
165
|
+
for _ in range(SUGGEST_TRIES):
|
|
166
|
+
box.click()
|
|
167
|
+
box.press("Control+a") # `fill("")`, select-all and Delete, leaves the profile's value
|
|
168
|
+
box.press("Backspace")
|
|
169
|
+
box.press_sequentially(answer, delay=30)
|
|
170
|
+
deadline = time.monotonic() + SUGGEST_TIMEOUT_S
|
|
171
|
+
while time.monotonic() < deadline:
|
|
172
|
+
for option in options.all():
|
|
173
|
+
if matching.matches(field, answer, option.inner_text().strip()):
|
|
174
|
+
option.click()
|
|
175
|
+
return
|
|
176
|
+
page.wait_for_timeout(250)
|
|
163
177
|
raise Unsupported(f"form_shape: no suggestion for {answer!r} in {field.id}")
|
|
164
178
|
|
|
165
179
|
|
|
@@ -177,4 +191,7 @@ def _field(raw: dict[str, Any]) -> Field | None:
|
|
|
177
191
|
)
|
|
178
192
|
if kind is None:
|
|
179
193
|
return None
|
|
180
|
-
return Field(
|
|
194
|
+
return Field(
|
|
195
|
+
name, raw["label"], kind, bool(raw["required"]), options, None, raw["accept"],
|
|
196
|
+
raw["prefilled"],
|
|
197
|
+
) # fmt: skip
|
|
@@ -91,7 +91,7 @@ def problems(fields: list[Field], answers: dict[str, Answer]) -> list[str]:
|
|
|
91
91
|
out += [
|
|
92
92
|
f"no answer for required {f.id} ({f.label})"
|
|
93
93
|
for f in fields
|
|
94
|
-
if f.required and f.id not in answers
|
|
94
|
+
if f.required and not f.prefilled and f.id not in answers
|
|
95
95
|
]
|
|
96
96
|
out += [
|
|
97
97
|
f"{i}: {problem}"
|
|
@@ -27,6 +27,7 @@ kind = "ats"
|
|
|
27
27
|
account = "none"
|
|
28
28
|
hosting = ["hosted"]
|
|
29
29
|
adapter = "teamtailor"
|
|
30
|
+
send_verified = { date = 2026-10-20, version = "0.0.3", by = "shbernal", commit = "4e09f53" }
|
|
30
31
|
dry_run_verified = { date = 2026-10-19, version = "0.0.2", by = "gate", commit = "91a0bba" }
|
|
31
32
|
|
|
32
33
|
[greenhouse]
|
|
@@ -41,3 +42,5 @@ kind = "board"
|
|
|
41
42
|
account = "to_send"
|
|
42
43
|
hosting = ["hosted"]
|
|
43
44
|
adapter = "wttj"
|
|
45
|
+
send_verified = { date = 2026-10-20, version = "0.0.3", by = "shbernal", commit = "65458a5" }
|
|
46
|
+
dry_run_verified = { date = 2026-10-19, version = "0.0.2", by = "gate", commit = "ad9f478" }
|
|
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
|