atsfill 0.0.3.dev7__tar.gz → 0.0.3.dev9__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.dev7 → atsfill-0.0.3.dev9}/PKG-INFO +2 -2
- {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/README.md +1 -1
- {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/pyproject.toml +1 -1
- {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/pyproject.toml.orig +1 -1
- {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/src/atsfill/adapters/greenhouse.py +37 -46
- {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/src/atsfill/registry.toml +1 -0
- {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/LICENSE +0 -0
- {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/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.dev9
|
|
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 |
|
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
pages embedding them), as docs/adapters/greenhouse.md observes.
|
|
3
3
|
|
|
4
4
|
Nothing but an attached file reaches Greenhouse before submit, so the read-back is the page:
|
|
5
|
-
input values, the shown value of each select, the phone
|
|
5
|
+
input values, the shown value of each select, the phone's dial code, the attached file's name.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import json
|
|
9
9
|
import re
|
|
10
10
|
import time
|
|
11
11
|
import urllib.request
|
|
12
|
+
from contextlib import suppress
|
|
12
13
|
from typing import Any
|
|
13
14
|
from urllib.parse import parse_qs, urlencode, urlsplit
|
|
14
15
|
|
|
@@ -25,6 +26,7 @@ EMBED_TIMEOUT_MS = 15_000
|
|
|
25
26
|
OPTIONS_TIMEOUT_MS = 3_000
|
|
26
27
|
HYDRATE_TIMEOUT_MS = 15_000
|
|
27
28
|
FORM_TIMEOUT_MS = 20_000
|
|
29
|
+
ANSWER_TIMEOUT_MS = 15_000 # an upload's file name, a suggestion: past it, a mismatch
|
|
28
30
|
SUBMIT_TIMEOUT_MS = 90_000
|
|
29
31
|
API_TYPES = {
|
|
30
32
|
"input_text": FieldType.TEXT,
|
|
@@ -34,6 +36,9 @@ API_TYPES = {
|
|
|
34
36
|
"multi_value_multi_select": FieldType.MULTI_CHOICE,
|
|
35
37
|
}
|
|
36
38
|
YES_NO = ["Yes", "No"]
|
|
39
|
+
# Sections the page adds or names its own way, or shows a question of only on another answer
|
|
40
|
+
# (EEOC race after Hispanic/Latino): `read_api` declines a posting with any (ADR 0011).
|
|
41
|
+
UNMAPPED = ("education", "compliance", "demographic_questions")
|
|
37
42
|
|
|
38
43
|
# The fields in page order. A select's options are read by opening it, from Python.
|
|
39
44
|
_READ = """form => {
|
|
@@ -48,12 +53,14 @@ _READ = """form => {
|
|
|
48
53
|
const required = el => el.getAttribute("aria-required") === "true" || el.required;
|
|
49
54
|
const found = [];
|
|
50
55
|
for (const el of form.querySelectorAll("input, textarea")) {
|
|
51
|
-
if (!el.id || ["hidden", "search", "file", "checkbox"
|
|
56
|
+
if (!el.id || ["hidden", "search", "file", "checkbox"].includes(el.type)) continue;
|
|
52
57
|
const combo = el.getAttribute("role") === "combobox";
|
|
58
|
+
if (combo && el.closest(".phone-input")) continue; // the phone's country, set by its number
|
|
53
59
|
const shell = el.closest(".select");
|
|
54
60
|
const multi = combo && !!shell?.querySelector(".select__value-container--is-multi");
|
|
55
61
|
found.push([el, {id: el.id, label: text(labelFor(el)), required: required(el),
|
|
56
|
-
kind: combo ? (multi ? "multi" : "select")
|
|
62
|
+
kind: combo ? (multi ? "multi" : "select")
|
|
63
|
+
: ["tel", "number", "radio"].includes(el.type) ? el.type : "text",
|
|
57
64
|
maxLength: el.maxLength > 0 ? el.maxLength : null}]);
|
|
58
65
|
}
|
|
59
66
|
for (const group of form.querySelectorAll("fieldset.checkbox")) {
|
|
@@ -80,8 +87,8 @@ _READ = """form => {
|
|
|
80
87
|
return found.map(([, field]) => field);
|
|
81
88
|
}"""
|
|
82
89
|
|
|
83
|
-
# What each field shows: a select its shown value
|
|
84
|
-
# its
|
|
90
|
+
# What each field shows: a select its shown value, a file its attached name, a checkbox group
|
|
91
|
+
# its checked labels, the phone its country's dial code then its number.
|
|
85
92
|
_READ_BACK = """(form, ids) => {
|
|
86
93
|
const text = el => el ? el.textContent.replace(/\\s+/g, " ").trim() : null;
|
|
87
94
|
const out = {};
|
|
@@ -94,10 +101,12 @@ _READ_BACK = """(form, ids) => {
|
|
|
94
101
|
.map(b => text(document.querySelector(`label[for="${CSS.escape(b.id)}"]`)));
|
|
95
102
|
} else if (el?.getAttribute("role") === "combobox") {
|
|
96
103
|
const select = el.closest(".select");
|
|
97
|
-
const flag = select.querySelector(".select__single-value .iti__flag");
|
|
98
104
|
const multi = [...select.querySelectorAll(".select__multi-value__label")].map(text);
|
|
99
|
-
out[id] =
|
|
100
|
-
|
|
105
|
+
out[id] = multi.length ? multi : text(select.querySelector(".select__single-value"));
|
|
106
|
+
} else if (el?.type === "tel" && el.closest(".phone-input")) {
|
|
107
|
+
const dial = text(el.closest(".phone-input").querySelector(".select__single-value"));
|
|
108
|
+
const number = el.value.startsWith(dial) ? el.value.slice(dial.length).trim() : el.value;
|
|
109
|
+
out[id] = dial && number ? `${dial} ${number}` : null;
|
|
101
110
|
} else {
|
|
102
111
|
const box = form.querySelector(`input[type=checkbox][name="${id}"]`) ?? el;
|
|
103
112
|
out[id] = box?.type === "checkbox" ? (box.checked ? "Yes" : "No") : el?.value ?? null;
|
|
@@ -117,11 +126,11 @@ class Greenhouse:
|
|
|
117
126
|
def __init__(self) -> None:
|
|
118
127
|
self._fields: dict[str, Field] = {}
|
|
119
128
|
self._kinds: dict[str, str] = {} # how the page renders each field
|
|
120
|
-
self._flags: dict[str, dict[str, str]] = {} # per select, option text by flag code
|
|
121
129
|
self._embed: str | None = None # the job_app URL a company page embeds
|
|
122
130
|
|
|
123
131
|
def detect(self, url: str, page: Page | None) -> bool:
|
|
124
132
|
"""A board URL, or a company page whose `#grnhse_iframe` embeds a job application."""
|
|
133
|
+
self._embed = None # one process detects many postings: a survey, the gate
|
|
125
134
|
if _board_job(url):
|
|
126
135
|
return True
|
|
127
136
|
if page is None:
|
|
@@ -181,7 +190,8 @@ class Greenhouse:
|
|
|
181
190
|
case "file":
|
|
182
191
|
upload = page.locator(_upload(field_id))
|
|
183
192
|
upload.locator("input[type=file]").set_input_files(values[0])
|
|
184
|
-
|
|
193
|
+
with suppress(Timeout):
|
|
194
|
+
upload.locator(".file-upload__filename").wait_for(timeout=ANSWER_TIMEOUT_MS)
|
|
185
195
|
case "consent":
|
|
186
196
|
box = page.locator(f"{FORM} input[type=checkbox][name='{field_id}']").first
|
|
187
197
|
if box.is_checked() != (answer == "Yes"):
|
|
@@ -194,15 +204,15 @@ class Greenhouse:
|
|
|
194
204
|
suggested = self._fields[field_id].type is FieldType.TYPEAHEAD
|
|
195
205
|
for value in values:
|
|
196
206
|
_select(page, field_id, value, suggested)
|
|
207
|
+
case "tel": # the number's dial code picks the country
|
|
208
|
+
page.locator(_by_id(field_id)).press_sequentially(values[0], delay=20)
|
|
209
|
+
case "number" if not re.fullmatch(r"-?\d+(\.\d+)?", values[0]):
|
|
210
|
+
pass # the input takes no text: left empty, it reads back as a mismatch
|
|
197
211
|
case _:
|
|
198
212
|
page.locator(_by_id(field_id)).fill(values[0])
|
|
199
213
|
|
|
200
214
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
201
215
|
values = page.locator(FORM).evaluate(_READ_BACK, list(self._fields))
|
|
202
|
-
for field_id, flags in self._flags.items():
|
|
203
|
-
shown = values.get(field_id)
|
|
204
|
-
if isinstance(shown, str) and shown.startswith("flag:"):
|
|
205
|
-
values[field_id] = flags.get(shown[5:], shown)
|
|
206
216
|
return {k: v for k, v in values.items() if v not in (None, "", [])}
|
|
207
217
|
|
|
208
218
|
def submit(self, page: Page) -> str | None:
|
|
@@ -213,7 +223,7 @@ class Greenhouse:
|
|
|
213
223
|
def _field(self, page: Page, raw: dict[str, Any]) -> Field:
|
|
214
224
|
kind, options = raw["kind"], raw.get("options")
|
|
215
225
|
match kind:
|
|
216
|
-
case "text":
|
|
226
|
+
case "text" | "number":
|
|
217
227
|
field_type = FieldType.TEXT
|
|
218
228
|
case "tel":
|
|
219
229
|
field_type = FieldType.PHONE
|
|
@@ -229,7 +239,7 @@ class Greenhouse:
|
|
|
229
239
|
field_type = FieldType.TYPEAHEAD
|
|
230
240
|
else:
|
|
231
241
|
field_type = FieldType.MULTI_CHOICE if kind == "multi" else FieldType.CHOICE
|
|
232
|
-
case _:
|
|
242
|
+
case _: # a radio input, not seen on a posting yet
|
|
233
243
|
raise Unsupported(f"form_shape: {raw['id']} is a {kind}")
|
|
234
244
|
return Field(
|
|
235
245
|
raw["id"],
|
|
@@ -252,12 +262,6 @@ class Greenhouse:
|
|
|
252
262
|
box.press("Escape")
|
|
253
263
|
return None
|
|
254
264
|
options = [t.strip() for t in menu.all_inner_texts()]
|
|
255
|
-
flags = menu.evaluate_all(
|
|
256
|
-
"items => items.map(i => [...(i.querySelector('.iti__flag')?.classList ?? [])]"
|
|
257
|
-
".find(c => /^iti__[a-z]{2}$/.test(c))?.slice(5))"
|
|
258
|
-
)
|
|
259
|
-
if any(flags):
|
|
260
|
-
self._flags[field_id] = {f: o for f, o in zip(flags, options, strict=True) if f}
|
|
261
265
|
box.press("Escape")
|
|
262
266
|
return options
|
|
263
267
|
|
|
@@ -294,39 +298,22 @@ def _board_job(url: str) -> tuple[str, str] | None:
|
|
|
294
298
|
|
|
295
299
|
|
|
296
300
|
def _api_fields(job: dict[str, Any]) -> list[Field] | None:
|
|
297
|
-
if job.get(
|
|
301
|
+
if any(job.get(k) for k in UNMAPPED) or any(
|
|
298
302
|
c.get("requires_consent") or c.get("requires_processing_consent")
|
|
299
303
|
for c in job.get("data_compliance") or []
|
|
300
304
|
):
|
|
301
|
-
return None
|
|
305
|
+
return None
|
|
302
306
|
fields = []
|
|
303
307
|
for question in job["questions"] + (job.get("location_questions") or []):
|
|
304
308
|
entry = question["fields"][0] # a file's paste-in textarea comes second
|
|
305
|
-
if entry["type"] == "input_hidden":
|
|
309
|
+
if entry["type"] == "input_hidden": # a location's latitude and longitude
|
|
306
310
|
continue
|
|
307
311
|
name = entry["name"]
|
|
308
|
-
field_type = API_TYPES[entry["type"]]
|
|
309
|
-
options = [v["label"] for v in entry.get("values") or []] or None
|
|
310
|
-
if name == "phone":
|
|
311
|
-
fields.append(Field("country", "Country", FieldType.CHOICE, question["required"]))
|
|
312
|
-
field_type = FieldType.PHONE
|
|
312
|
+
field_type = FieldType.PHONE if name == "phone" else API_TYPES[entry["type"]]
|
|
313
313
|
if name == "location":
|
|
314
314
|
name, field_type = "candidate-location", FieldType.TYPEAHEAD
|
|
315
|
+
options = [v["label"] for v in entry.get("values") or []] or None
|
|
315
316
|
fields.append(Field(name, question["label"], field_type, question["required"], options))
|
|
316
|
-
for section in job.get("compliance") or []:
|
|
317
|
-
for question in section["questions"]:
|
|
318
|
-
entry = question["fields"][0]
|
|
319
|
-
options = [v["label"] for v in entry.get("values") or []] or None
|
|
320
|
-
kind = API_TYPES[entry["type"]]
|
|
321
|
-
fields.append(
|
|
322
|
-
Field(entry["name"], question["label"], kind, question["required"], options)
|
|
323
|
-
)
|
|
324
|
-
for question in (job.get("demographic_questions") or {}).get("questions", []):
|
|
325
|
-
options = [o["label"] for o in question["answer_options"]]
|
|
326
|
-
kind = API_TYPES[question["type"]]
|
|
327
|
-
fields.append(
|
|
328
|
-
Field(str(question["id"]), question["label"], kind, question["required"], options)
|
|
329
|
-
)
|
|
330
317
|
return fields
|
|
331
318
|
|
|
332
319
|
|
|
@@ -345,12 +332,16 @@ def _exact(value: str) -> re.Pattern[str]:
|
|
|
345
332
|
|
|
346
333
|
def _select(page: Page, field_id: str, value: str, suggested: bool) -> None:
|
|
347
334
|
"""Types into a react-select and picks the option: hovered then Enter, which every select
|
|
348
|
-
takes
|
|
335
|
+
takes. A suggestion list takes its first suggestion. No option leaves the select empty."""
|
|
349
336
|
box = page.locator(_by_id(field_id))
|
|
350
337
|
box.click()
|
|
351
338
|
box.press_sequentially(value, delay=20)
|
|
352
339
|
menu = page.locator(f"{_by_id(f'react-select-{field_id}-listbox')} [role=option]")
|
|
353
340
|
option = menu.first if suggested else menu.filter(has_text=_exact(value)).first
|
|
354
|
-
|
|
341
|
+
try:
|
|
342
|
+
option.wait_for(timeout=ANSWER_TIMEOUT_MS)
|
|
343
|
+
except Timeout:
|
|
344
|
+
box.press("Escape")
|
|
345
|
+
return
|
|
355
346
|
option.hover()
|
|
356
347
|
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
|