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.
Files changed (24) hide show
  1. {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/PKG-INFO +2 -2
  2. {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/README.md +1 -1
  3. {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/pyproject.toml +1 -1
  4. {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/src/atsfill/adapters/greenhouse.py +37 -46
  6. {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/src/atsfill/registry.toml +1 -0
  7. {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/LICENSE +0 -0
  8. {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/src/atsfill/__init__.py +0 -0
  9. {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/src/atsfill/adapters/__init__.py +0 -0
  10. {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/src/atsfill/adapters/ashby.py +0 -0
  11. {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/src/atsfill/adapters/teamtailor.py +0 -0
  12. {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/src/atsfill/adapters/wttj.py +0 -0
  13. {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/src/atsfill/cli.py +0 -0
  14. {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/src/atsfill/core/__init__.py +0 -0
  15. {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/src/atsfill/core/accounts.py +0 -0
  16. {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/src/atsfill/core/adapter.py +0 -0
  17. {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/src/atsfill/core/attempts.py +0 -0
  18. {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/src/atsfill/core/browser.py +0 -0
  19. {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/src/atsfill/core/commands.py +0 -0
  20. {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/src/atsfill/core/matching.py +0 -0
  21. {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/src/atsfill/core/paths.py +0 -0
  22. {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/src/atsfill/core/registry.py +0 -0
  23. {atsfill-0.0.3.dev7 → atsfill-0.0.3.dev9}/src/atsfill/core/results.py +0 -0
  24. {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.dev7
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
- ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![greenhouse: unverified](https://img.shields.io/badge/greenhouse-unverified-565f89) ![teamtailor: unverified](https://img.shields.io/badge/teamtailor-unverified-565f89) ![wttj: unverified](https://img.shields.io/badge/wttj-unverified-565f89)
56
+ ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![teamtailor: unverified](https://img.shields.io/badge/teamtailor-unverified-565f89) ![wttj: unverified](https://img.shields.io/badge/wttj-unverified-565f89)
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
- ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![greenhouse: unverified](https://img.shields.io/badge/greenhouse-unverified-565f89) ![teamtailor: unverified](https://img.shields.io/badge/teamtailor-unverified-565f89) ![wttj: unverified](https://img.shields.io/badge/wttj-unverified-565f89)
32
+ ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![teamtailor: unverified](https://img.shields.io/badge/teamtailor-unverified-565f89) ![wttj: unverified](https://img.shields.io/badge/wttj-unverified-565f89)
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.3.dev7"
3
+ version = "0.0.3.dev9"
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.3.dev7"
3
+ version = "0.0.3.dev9"
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"
@@ -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 country's flag, the attached file's name.
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", "radio"].includes(el.type)) continue;
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") : el.type === "tel" ? "tel" : "text",
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 (the phone country its flag's code), a file
84
- # its attached name, a checkbox group its checked labels.
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] = flag ? "flag:" + [...flag.classList].find(c => /^iti__[a-z]{2}$/.test(c)).slice(5)
100
- : multi.length ? multi : text(select.querySelector(".select__single-value"));
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
- upload.locator(".file-upload__filename").wait_for()
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("education") or any(
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 # the page adds education rows or consent boxes the API does not list
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 (Country saves only on Enter). A suggestion list takes its first suggestion."""
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
- option.wait_for()
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")
@@ -33,6 +33,7 @@ kind = "ats"
33
33
  account = "none"
34
34
  hosting = ["hosted", "embedded"]
35
35
  adapter = "greenhouse"
36
+ dry_run_verified = { date = 2026-10-19, version = "0.0.2", by = "gate", commit = "c0fb0c5" }
36
37
 
37
38
  [wttj]
38
39
  kind = "board"
File without changes