atsfill 0.0.3.dev13__tar.gz → 0.0.3.dev14__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.dev14}/PKG-INFO +2 -2
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/README.md +1 -1
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/pyproject.toml +1 -1
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/pyproject.toml.orig +1 -1
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/src/atsfill/adapters/wttj.py +32 -15
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/src/atsfill/core/matching.py +1 -1
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/src/atsfill/core/results.py +1 -0
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/src/atsfill/registry.toml +1 -0
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/LICENSE +0 -0
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev14}/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.dev14
|
|
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 |
|
|
@@ -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}"
|
|
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
|