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.
Files changed (24) hide show
  1. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/PKG-INFO +2 -2
  2. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/README.md +1 -1
  3. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/pyproject.toml +1 -1
  4. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/src/atsfill/adapters/teamtailor.py +23 -10
  6. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/src/atsfill/adapters/wttj.py +32 -15
  7. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/src/atsfill/core/matching.py +1 -1
  8. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/src/atsfill/core/results.py +1 -0
  9. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/src/atsfill/registry.toml +3 -0
  10. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/LICENSE +0 -0
  11. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/src/atsfill/__init__.py +0 -0
  12. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/src/atsfill/adapters/__init__.py +0 -0
  13. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/src/atsfill/adapters/ashby.py +0 -0
  14. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/src/atsfill/adapters/greenhouse.py +0 -0
  15. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/src/atsfill/cli.py +0 -0
  16. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/src/atsfill/core/__init__.py +0 -0
  17. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/src/atsfill/core/accounts.py +0 -0
  18. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/src/atsfill/core/adapter.py +0 -0
  19. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/src/atsfill/core/attempts.py +0 -0
  20. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/src/atsfill/core/browser.py +0 -0
  21. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/src/atsfill/core/commands.py +0 -0
  22. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/src/atsfill/core/paths.py +0 -0
  23. {atsfill-0.0.3.dev13 → atsfill-0.0.3.dev16}/src/atsfill/core/registry.py +0 -0
  24. {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.dev13
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
- ![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: dry run verified 2026-10-19](https://img.shields.io/badge/teamtailor-dry%20run%20verified%202026--10--19-7aa2f7) ![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: 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)
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: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![teamtailor: dry run verified 2026-10-19](https://img.shields.io/badge/teamtailor-dry%20run%20verified%202026--10--19-7aa2f7) ![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: 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)
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.dev13"
3
+ version = "0.0.3.dev16"
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.dev13"
3
+ version = "0.0.3.dev16"
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"
@@ -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._wait_for_uploads(form, answers)
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/\d+/thanks"), timeout=SUBMIT_TIMEOUT_MS)
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, answers: dict[str, Answer]) -> None:
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 files and time.monotonic() < deadline:
306
+ while self._uploads and time.monotonic() < deadline:
301
307
  held = form.evaluate(_READ_BACK, QUESTION)
302
- if all(held.get(i) == name for i, name in files.items()):
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([name=cover_letter])"
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
- remove = target.locator("xpath=..").locator("button[title=Remove]")
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
- form.locator(f'[data-id="{answer.rsplit("/", 1)[-1]}"]').wait_for()
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
- box.click()
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
- options.first.wait_for()
159
- for i in range(options.count()):
160
- if matching.matches(field, answer, options.nth(i).inner_text().strip()):
161
- options.nth(i).click()
162
- return
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(name, raw["label"], kind, bool(raw["required"]), options, None, raw["accept"])
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}"
@@ -34,6 +34,7 @@ class Field:
34
34
  options: list[str] | None = None
35
35
  max_length: int | None = None
36
36
  accept: list[str] | None = None
37
+ prefilled: bool = False # holds a value before any answer, from the account's profile
37
38
 
38
39
 
39
40
  @dataclass(frozen=True)
@@ -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